├── docs ├── Makefile ├── images │ └── container-agent-si.png ├── architecture.dot └── DESIGN.md ├── config ├── default.go ├── plugin.go ├── env_test.go ├── env.go ├── loader.go ├── s3.go ├── probe.go └── loader_test.go ├── internal └── amazon-ecs-agent │ ├── README.md │ └── agent │ ├── containermetadata │ └── types.go │ ├── handlers │ ├── v1 │ │ └── response.go │ └── v2 │ │ └── response.go │ └── api │ └── container │ ├── container.go │ └── status │ └── containerstatus.go ├── platform ├── ecs │ ├── units.go │ ├── provider.go │ ├── internal │ │ └── mock_taskmetadatagetter.go │ ├── taskmetadata │ │ ├── testdata │ │ │ ├── metadata_ec2_host.json │ │ │ ├── metadata_ec2_bridge.json │ │ │ ├── metadata_ecs_anywhere.json │ │ │ ├── metadata_fargate.json │ │ │ └── metadata_ec2_awsvpc.json │ │ ├── client.go │ │ └── client_test.go │ ├── spec_test.go │ ├── types.go │ ├── spec.go │ ├── metric_test.go │ ├── metric.go │ └── ecs.go ├── types.go ├── platform.go ├── none │ └── none.go └── kubernetes │ ├── kubelet │ ├── mock_client.go │ └── client.go │ ├── kubernetes_test.go │ ├── metric.go │ ├── spec_test.go │ ├── metric_test.go │ └── kubernetes.go ├── example ├── check-dice.sh ├── env.sh └── dice.sh ├── .gitignore ├── metric ├── sanitize.go ├── generator.go ├── hostinfo │ ├── generator_test.go │ ├── mock_generator.go │ └── generator.go ├── sanitize_test.go ├── collector_test.go ├── interface_test.go ├── mock_generator.go ├── manager_test.go ├── collector.go ├── sender.go ├── interface.go ├── generator_test.go ├── manager.go ├── plugin_test.go └── plugin.go ├── plugins └── tools.go ├── spec ├── generator.go ├── mock_generator.go ├── interface_test.go ├── sender.go ├── collector.go ├── cpu.go ├── interface.go ├── manager.go └── manager_test.go ├── RELEASE.md ├── agent ├── retire.go ├── platform.go ├── run.go └── agent.go ├── Makefile ├── check ├── generator.go ├── generator_test.go ├── mock_generator.go ├── collector.go ├── sender.go ├── manager.go ├── plugin.go ├── manager_test.go └── plugin_test.go ├── api └── client.go ├── script └── build-and-push-dockerimage ├── probe ├── tcp.go ├── tcp_test.go ├── exec.go ├── exec_test.go ├── probe.go ├── http.go ├── probe_test.go └── http_test.go ├── README.md ├── cmdutil ├── cmdutil.go ├── command_test.go ├── command.go └── cmdutil_test.go ├── .github ├── workflows │ └── create-release-pr.yml └── dependabot.yml ├── Dockerfile ├── cmd └── mackerel-container-agent │ └── main.go └── go.mod /docs/Makefile: -------------------------------------------------------------------------------- 1 | architecture.svg: architecture.dot 2 | dot $< -o $@ -Tsvg 3 | -------------------------------------------------------------------------------- /config/default.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | func defaultConfig() *Config { 4 | return &Config{ 5 | Root: defaultRoot, 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /docs/images/container-agent-si.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mackerelio/mackerel-container-agent/HEAD/docs/images/container-agent-si.png -------------------------------------------------------------------------------- /internal/amazon-ecs-agent/README.md: -------------------------------------------------------------------------------- 1 | # amazon-ecs-agent 2 | 3 | Forked from github.com/aws/amazon-ecs-agent@v1.52.2 to support agent/handlers/v2.TaskResponse 4 | -------------------------------------------------------------------------------- /platform/ecs/units.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | const ( 4 | // KiB represents kibibyte 5 | KiB = 1024 6 | // MiB represents mebibyte 7 | MiB = 1024 * KiB 8 | ) 9 | -------------------------------------------------------------------------------- /example/check-dice.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | num=$((RANDOM % 6 + 1)) 4 | 5 | if [ "$num" -gt 5 ]; then 6 | printf "%s" "$num" 7 | exit 2 8 | elif [ "$num" -gt 3 ]; then 9 | printf "%s" "$num" 10 | exit 1 11 | else 12 | printf "%s" "$num" 13 | exit 0 14 | fi 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | /build/ 15 | Gopkg.lock 16 | -------------------------------------------------------------------------------- /metric/sanitize.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import "regexp" 4 | 5 | var sanitizerReg = regexp.MustCompile(`[^A-Za-z0-9_-]`) 6 | 7 | // SanitizeMetricKey sanitize metric keys to be Mackerel friendly 8 | func SanitizeMetricKey(key string) string { 9 | return sanitizerReg.ReplaceAllString(key, "_") 10 | } 11 | -------------------------------------------------------------------------------- /platform/ecs/provider.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | type provider string 4 | 5 | // Supported providers 6 | const ( 7 | ecsProvider provider = "ecs" 8 | fargateProvider provider = "fargate" 9 | ecsManagedProvider provider = "ecs-managed" 10 | // experimental 11 | ecsAnywhereProvider provider = "ecs-anywhere" 12 | ) 13 | -------------------------------------------------------------------------------- /plugins/tools.go: -------------------------------------------------------------------------------- 1 | package plugins 2 | 3 | import ( 4 | _ "github.com/mackerelio/go-check-plugins" 5 | _ "github.com/mackerelio/mackerel-agent-plugins" 6 | _ "github.com/mackerelio/mackerel-plugin-json" 7 | _ "github.com/mackerelio/mkr" 8 | ) 9 | 10 | // see https://github.com/mackerelio/mackerel-container-agent/pull/471 11 | -------------------------------------------------------------------------------- /metric/generator.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "context" 5 | 6 | mackerel "github.com/mackerelio/mackerel-client-go" 7 | ) 8 | 9 | // Values represents metric values 10 | type Values map[string]float64 11 | 12 | // Generator interface generates metrics 13 | type Generator interface { 14 | Generate(context.Context) (Values, error) 15 | GetGraphDefs(context.Context) ([]*mackerel.GraphDefsParam, error) 16 | } 17 | -------------------------------------------------------------------------------- /spec/generator.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "context" 5 | 6 | mackerel "github.com/mackerelio/mackerel-client-go" 7 | ) 8 | 9 | // Generator interface generates spec information 10 | type Generator interface { 11 | Generate(context.Context) (any, error) 12 | } 13 | 14 | // CloudHostname has mackerel.Cloud and host name 15 | type CloudHostname struct { 16 | Cloud *mackerel.Cloud 17 | Hostname string 18 | } 19 | -------------------------------------------------------------------------------- /metric/hostinfo/generator_test.go: -------------------------------------------------------------------------------- 1 | package hostinfo 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNewGenerator(t *testing.T) { 8 | g := NewGenerator() 9 | memTotal, cpuCores, err := g.Generate() 10 | if err != nil { 11 | t.Errorf("should not return error, but %v", err) 12 | } 13 | if memTotal == 0 { 14 | t.Error("memTotal should not be 0, but 0") 15 | } 16 | if cpuCores == 0 { 17 | t.Error("cpuCores should not be 0, but 0") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /platform/types.go: -------------------------------------------------------------------------------- 1 | package platform 2 | 3 | // Type represents supported platform types 4 | type Type string 5 | 6 | // Supported platform types 7 | const ( 8 | ECS Type = "ecs" 9 | ECSAwsvpc Type = "ecs_awsvpc" 10 | ECSv3 Type = "ecs_v3" 11 | Fargate Type = "fargate" 12 | Kubernetes Type = "kubernetes" 13 | EKSOnFargate Type = "eks_fargate" 14 | None Type = "none" 15 | // experimental 16 | ECSAnywhere Type = "ecs_anywhere" 17 | ) 18 | -------------------------------------------------------------------------------- /platform/platform.go: -------------------------------------------------------------------------------- 1 | package platform 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/mackerelio/mackerel-container-agent/metric" 7 | "github.com/mackerelio/mackerel-container-agent/spec" 8 | ) 9 | 10 | // Platform interface gets metric values and metadata 11 | type Platform interface { 12 | GetMetricGenerators() []metric.Generator 13 | GetSpecGenerators() []spec.Generator 14 | GetCustomIdentifier(context.Context) (string, error) 15 | StatusRunning(context.Context) bool 16 | } 17 | -------------------------------------------------------------------------------- /spec/mock_generator.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import "context" 4 | 5 | // MockGenerator represents a mock spec generator 6 | type MockGenerator struct { 7 | value any 8 | errValue error 9 | } 10 | 11 | // NewMockGenerator creates a new mock spec generator 12 | func NewMockGenerator(value any, errValue error) Generator { 13 | return &MockGenerator{value, errValue} 14 | } 15 | 16 | // Generate generates spec values 17 | func (g *MockGenerator) Generate(context.Context) (any, error) { 18 | return g.value, g.errValue 19 | } 20 | -------------------------------------------------------------------------------- /example/env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | NUM=${NUM:-6} 4 | 5 | if [ "$MACKEREL_AGENT_PLUGIN_META" = 1 ]; then 6 | cat << EOF 7 | # mackerel-agent-plugin 8 | { 9 | "graphs": { 10 | "dice": { 11 | "label": "My Dice $NUM", 12 | "unit": "integer", 13 | "metrics": [ 14 | { 15 | "label": "Die $NUM", 16 | "name": "d$NUM" 17 | } 18 | ] 19 | } 20 | } 21 | } 22 | EOF 23 | exit 0 24 | fi 25 | 26 | printf 'dice.d%s\t%s\t%s\n' "$NUM" $((RANDOM % NUM + 1)) "$(date +%s)" 27 | -------------------------------------------------------------------------------- /metric/sanitize_test.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import "testing" 4 | 5 | func Test_SanitizeMetricKey(t *testing.T) { 6 | testCases := []struct { 7 | src, expected string 8 | }{ 9 | {"", ""}, 10 | {"foo.bar", "foo_bar"}, 11 | {"foo-^bar.qux#&quux", "foo-_bar_qux__quux"}, 12 | } 13 | for _, testCase := range testCases { 14 | got := SanitizeMetricKey(testCase.src) 15 | if got != testCase.expected { 16 | t.Errorf("SanitizeMetricKey(%q) should be %q but got %q", testCase.src, testCase.expected, got) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Runbook 2 | 3 | - Merge PRs to master 4 | - Create release PR 5 | - Run action `Create Release PR`. 6 | - `mackerel-container-agent:-alpha` Docker image is automatically pushed. 7 | - Check release PR 8 | - Check CHANGELOG. 9 | - Test `mackerel-container-agent:-alpha` Docker image. 10 | - Merge release PR 11 | - Tag `` 12 | - Run `git tag v$(make -s version)` and `git push --tags`. 13 | - `mackerel-container-agent:` Docker image is automatically pushed. 14 | -------------------------------------------------------------------------------- /config/plugin.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/mackerelio/mackerel-container-agent/cmdutil" 7 | ) 8 | 9 | // MetricPlugin represents metric plugin 10 | type MetricPlugin struct { 11 | Name string 12 | Command cmdutil.Command 13 | User string 14 | Env Env 15 | Timeout time.Duration 16 | } 17 | 18 | // CheckPlugin represents check plugin 19 | type CheckPlugin struct { 20 | Name string 21 | Command cmdutil.Command 22 | User string 23 | Env Env 24 | Timeout time.Duration 25 | Memo string 26 | } 27 | -------------------------------------------------------------------------------- /config/env_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestMaskEnvValue(t *testing.T) { 8 | testCases := []struct { 9 | envValue string 10 | expect string 11 | }{ 12 | { 13 | envValue: "AAA", 14 | expect: "AAA", 15 | }, 16 | { 17 | envValue: "BBBBBBBBBBB", 18 | expect: "BBBB***", 19 | }, 20 | { 21 | envValue: "CCC CC", 22 | expect: "CCC ***", 23 | }, 24 | } 25 | 26 | for _, tc := range testCases { 27 | if MaskEnvValue(tc.envValue) != tc.expect { 28 | t.Fatalf("expect %s, actual %s", tc.expect, MaskEnvValue(tc.envValue)) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /example/dice.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ "$MACKEREL_AGENT_PLUGIN_META" = 1 ]; then 4 | cat << EOF 5 | # mackerel-agent-plugin 6 | { 7 | "graphs": { 8 | "dice": { 9 | "label": "My Dice", 10 | "unit": "integer", 11 | "metrics": [ 12 | { 13 | "label": "Die 6", 14 | "name": "d6" 15 | }, 16 | { 17 | "label": "Die 20", 18 | "name": "d20" 19 | } 20 | ] 21 | } 22 | } 23 | } 24 | EOF 25 | exit 0 26 | fi 27 | 28 | printf 'dice.d6\t%s\t%s\n' $((RANDOM % 6 + 1)) "$(date +%s)" 29 | printf 'dice.d20\t%s\t%s\n' $((RANDOM % 20 + 1)) "$(date +%s)" 30 | -------------------------------------------------------------------------------- /metric/hostinfo/mock_generator.go: -------------------------------------------------------------------------------- 1 | package hostinfo 2 | 3 | // MockGenerator is mock for testing 4 | type MockGenerator struct { 5 | mockCPUCores float64 6 | mockMemTotal float64 7 | mockErr error 8 | } 9 | 10 | // Generate returns mock response 11 | func (m *MockGenerator) Generate() (float64, float64, error) { 12 | return m.mockMemTotal, m.mockCPUCores, m.mockErr 13 | } 14 | 15 | // NewMockGenerator is constructor for mock 16 | func NewMockGenerator(mockMemTotal float64, mockCPUCores float64, mockErr error) Generator { 17 | return &MockGenerator{ 18 | mockCPUCores: mockCPUCores, 19 | mockMemTotal: mockMemTotal, 20 | mockErr: mockErr, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /agent/retire.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/Songmu/retry" 7 | 8 | "github.com/mackerelio/mackerel-container-agent/api" 9 | ) 10 | 11 | func retire(client api.Client, hostResolver *hostResolver) error { 12 | hostID, notExist, err := hostResolver.getLocalHostID() 13 | if err != nil { 14 | if notExist { // ignore error when the host is not created yet 15 | return nil 16 | } 17 | return err 18 | } 19 | logger.Infof("retire: host id = %s", hostID) 20 | err = retry.Retry(3, 3*time.Second, func() error { 21 | return client.RetireHost(hostID) 22 | }) 23 | if err != nil { 24 | return err 25 | } 26 | return hostResolver.removeHostID() 27 | } 28 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN := mackerel-container-agent 2 | 3 | .PHONY: all 4 | all: clean build 5 | 6 | .PHONY: build 7 | build: 8 | go build -o build/$(BIN) ./cmd/$(BIN)/... 9 | 10 | .PHONY: test 11 | test: 12 | go test -v ./... 13 | 14 | .PHONY: lint 15 | lint: 16 | golangci-lint run 17 | 18 | .PHONY: clean 19 | clean: 20 | rm -fr build 21 | go clean ./... 22 | 23 | .PHONY: linux 24 | linux: 25 | GOOS=linux go build -o build/$(BIN) ./cmd/$(BIN)/... 26 | 27 | .PHONY: docker 28 | docker: 29 | docker build -t $(BIN) -t $(BIN):local --target container-agent . 30 | 31 | .PHONY: docker-with-plugins 32 | docker-with-plugins: 33 | docker build -t $(BIN) -t $(BIN):local --target container-agent-with-plugins . 34 | -------------------------------------------------------------------------------- /check/generator.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | mackerel "github.com/mackerelio/mackerel-client-go" 8 | ) 9 | 10 | // Result represents check plugin result 11 | type Result struct { 12 | name string 13 | message string 14 | status mackerel.CheckStatus 15 | occurredAt time.Time 16 | } 17 | 18 | // NewResult creates a new Result 19 | func NewResult(name string, message string, status mackerel.CheckStatus, occurredAt time.Time) *Result { 20 | return &Result{name, message, status, occurredAt} 21 | } 22 | 23 | // Generator interface generate check plugin result 24 | type Generator interface { 25 | Generate(context.Context) (*Result, error) 26 | Config() mackerel.CheckConfig 27 | } 28 | -------------------------------------------------------------------------------- /api/client.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import mackerel "github.com/mackerelio/mackerel-client-go" 4 | 5 | // Client represents a client of Mackerel API 6 | type Client interface { 7 | FindHost(id string) (*mackerel.Host, error) 8 | FindHosts(param *mackerel.FindHostsParam) ([]*mackerel.Host, error) 9 | CreateHost(param *mackerel.CreateHostParam) (string, error) 10 | UpdateHost(hostID string, param *mackerel.UpdateHostParam) (string, error) 11 | UpdateHostStatus(hostID string, status string) error 12 | RetireHost(id string) error 13 | PostHostMetricValuesByHostID(hostID string, metricValues []*mackerel.MetricValue) error 14 | CreateGraphDefs([]*mackerel.GraphDefsParam) error 15 | PostCheckReports(reports *mackerel.CheckReports) error 16 | } 17 | -------------------------------------------------------------------------------- /spec/interface_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import "testing" 4 | 5 | func TestGetInterfaces(t *testing.T) { 6 | ifaces, err := getInterfaces() 7 | if err != nil { 8 | t.Errorf("should not raise error: %v", err) 9 | } 10 | 11 | if len(ifaces) == 0 { 12 | t.Error("should have at least 1 interface") 13 | } 14 | 15 | iface := ifaces[0] 16 | if iface.Name == "" { 17 | t.Error("interface should have Name") 18 | } 19 | if iface.IPAddress == "" { 20 | t.Error("interface should have IPAddresses") 21 | } 22 | if len(iface.IPv4Addresses) == 0 && len(iface.IPv6Addresses) == 0 { 23 | t.Error("interface should have IPv4Address or IPv6Address") 24 | } 25 | if iface.MacAddress == "" { 26 | t.Errorf("interface should have MacAddress") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /metric/hostinfo/generator.go: -------------------------------------------------------------------------------- 1 | package hostinfo 2 | 3 | import ( 4 | "runtime" 5 | 6 | "github.com/mackerelio/go-osstat/memory" 7 | ) 8 | 9 | // Generator interface gets host information 10 | type Generator interface { 11 | Generate() (memTotal float64, cpuCores float64, err error) 12 | } 13 | 14 | // NewGenerator returns host info generator 15 | func NewGenerator() Generator { 16 | return &generator{} 17 | } 18 | 19 | // generator is a real Generator 20 | type generator struct{} 21 | 22 | // Generate retrieves information and return it 23 | func (r *generator) Generate() (memTotal float64, cpuCores float64, err error) { 24 | memory, err := memory.Get() 25 | if err != nil { 26 | return 0, 0, err 27 | } 28 | return float64(memory.Total), float64(runtime.NumCPU()), nil 29 | } 30 | -------------------------------------------------------------------------------- /spec/sender.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "sync" 5 | 6 | mackerel "github.com/mackerelio/mackerel-client-go" 7 | 8 | "github.com/mackerelio/mackerel-container-agent/api" 9 | ) 10 | 11 | type sender struct { 12 | client api.Client 13 | hostID string 14 | mu sync.Mutex 15 | } 16 | 17 | func newSender(client api.Client) *sender { 18 | return &sender{client: client} 19 | } 20 | 21 | func (s *sender) post(param *mackerel.UpdateHostParam) error { 22 | s.mu.Lock() 23 | defer s.mu.Unlock() 24 | if s.hostID == "" { // skip updating host spec until host id is resolved 25 | return nil 26 | } 27 | _, err := s.client.UpdateHost(s.hostID, param) 28 | return err 29 | } 30 | 31 | func (s *sender) setHostID(hostID string) { 32 | s.mu.Lock() 33 | defer s.mu.Unlock() 34 | s.hostID = hostID 35 | } 36 | -------------------------------------------------------------------------------- /script/build-and-push-dockerimage: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | DOCKER_REPOSITORY=${DOCKER_REPOSITORY:-"mackerel/mackerel-container-agent"} 6 | DOCKER_USER=${DOCKER_USER:-$(read -r -p "Docker Hub User: " __user && echo -n "${__user}")} 7 | DOCKER_IMAGE_TAG=${DOCKER_IMAGE_TAG:-"v$(make --silent version)-$(git branch --contains | awk '{print $2}')"} 8 | 9 | if [[ "${DOCKER_IMAGE_TAG}" == "latest" || "${DOCKER_IMAGE_TAG}" =~ ^v[0-9]+[.][0-9]+[.][0-9]+-alpha$ ]]; then 10 | echo "tag \"${DOCKER_IMAGE_TAG}\" is not allowed" 11 | exit 1 12 | fi 13 | 14 | image="${DOCKER_REPOSITORY}:${DOCKER_IMAGE_TAG}" 15 | 16 | read -r -p "Docker Hub Password [${DOCKER_USER}]: " -s passwd 17 | echo -n "${passwd}" | docker login --username "${DOCKER_USER}" --password-stdin 18 | 19 | set -x 20 | docker build -t "${image}" --target container-agent . 21 | docker push "${image}" 22 | -------------------------------------------------------------------------------- /metric/collector_test.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestCollectorCollect(t *testing.T) { 10 | ctx := context.Background() 11 | c := newCollector(createMockGenerators()) 12 | values, err := c.collect(ctx) 13 | 14 | if err != nil { 15 | t.Errorf("error should be nil but got: %+v", err) 16 | } 17 | expectedValues := Values{ 18 | "loadavg5": 2.39, 19 | "cpu.user.percentage": 29.2, 20 | "custom.foo.bar": 10.0, 21 | "custom.foo.baz": 20.0, 22 | "custom.foo.qux": 30.0, 23 | "custom.qux.a.bar": 12.39, 24 | "custom.qux.a.baz": 13.41, 25 | "custom.qux.b.bar": 14.43, 26 | "custom.qux.b.baz": 15.45, 27 | } 28 | if !reflect.DeepEqual(values, expectedValues) { 29 | t.Errorf("values should be %+v but got: %+v", expectedValues, values) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /metric/interface_test.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestInterfaceGenerator(t *testing.T) { 11 | ctx := context.Background() 12 | generator := NewInterfaceGenerator() 13 | values, err := generator.Generate(ctx) 14 | if err != nil { 15 | t.Errorf("should not raise error: %v", err) 16 | } 17 | if values != nil { 18 | t.Errorf("should not generate values") 19 | } 20 | 21 | time.Sleep(time.Second * 1) 22 | 23 | values, err = generator.Generate(ctx) 24 | if err != nil { 25 | t.Errorf("should not raise error: %v", err) 26 | } 27 | if len(values) == 0 { 28 | t.Errorf("should generate values") 29 | } 30 | 31 | for name := range values { 32 | if strings.HasPrefix(name, "interface.veth") { 33 | t.Errorf("value for %s should not generate values", name) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /platform/none/none.go: -------------------------------------------------------------------------------- 1 | package none 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/mackerelio/mackerel-container-agent/metric" 7 | "github.com/mackerelio/mackerel-container-agent/platform" 8 | "github.com/mackerelio/mackerel-container-agent/spec" 9 | ) 10 | 11 | type nonePlatform struct{} 12 | 13 | // NewNonePlatform creates a new Platform 14 | func NewNonePlatform() (platform.Platform, error) { 15 | return &nonePlatform{}, nil 16 | } 17 | 18 | func (p *nonePlatform) GetMetricGenerators() []metric.Generator { 19 | return []metric.Generator{} 20 | } 21 | 22 | func (p *nonePlatform) GetSpecGenerators() []spec.Generator { 23 | return []spec.Generator{} 24 | } 25 | 26 | func (p *nonePlatform) GetCustomIdentifier(context.Context) (string, error) { 27 | return "", nil 28 | } 29 | 30 | func (p *nonePlatform) StatusRunning(context.Context) bool { 31 | return true 32 | } 33 | -------------------------------------------------------------------------------- /check/generator_test.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | mackerel "github.com/mackerelio/mackerel-client-go" 8 | ) 9 | 10 | func createMockGenerators() []Generator { 11 | now := time.Now() 12 | g1 := NewMockGenerator("g1", "g1 memo", []*Result{ 13 | NewResult("g1", "g1 ok", mackerel.CheckStatusOK, now), 14 | }, nil) 15 | g2 := NewMockGenerator("g2", "g2 memo", []*Result{ 16 | NewResult("g2", "g2 ok", mackerel.CheckStatusOK, now), 17 | NewResult("g2", "g2 warning", mackerel.CheckStatusWarning, now.Add(time.Minute)), 18 | NewResult("g2", "g2 critical", mackerel.CheckStatusCritical, now.Add(2*time.Minute)), 19 | nil, 20 | NewResult("g2", "g2 ok", mackerel.CheckStatusOK, now.Add(4*time.Minute)), 21 | }, nil) 22 | g3 := NewMockGenerator( 23 | "g3", "g3 memo", nil, errors.New("failed to exec check plugin"), 24 | ) 25 | return []Generator{g1, g2, g3} 26 | } 27 | -------------------------------------------------------------------------------- /spec/collector.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "context" 5 | 6 | mackerel "github.com/mackerelio/mackerel-client-go" 7 | ) 8 | 9 | type collector struct { 10 | generators []Generator 11 | } 12 | 13 | func newCollector(generators []Generator) *collector { 14 | return &collector{ 15 | generators: generators, 16 | } 17 | } 18 | 19 | func (c *collector) collect(ctx context.Context) (mackerel.HostMeta, string, error) { 20 | var ret mackerel.HostMeta 21 | var hostname string 22 | 23 | for _, g := range c.generators { 24 | v, err := g.Generate(ctx) 25 | if err != nil { 26 | return ret, hostname, err 27 | } 28 | switch v := v.(type) { 29 | case *mackerel.Cloud: 30 | ret.Cloud = v 31 | case *CloudHostname: 32 | ret.Cloud = v.Cloud 33 | hostname = v.Hostname 34 | case mackerel.CPU: 35 | ret.CPU = v 36 | default: 37 | } 38 | } 39 | 40 | return ret, hostname, nil 41 | } 42 | -------------------------------------------------------------------------------- /metric/mock_generator.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "context" 5 | 6 | mackerel "github.com/mackerelio/mackerel-client-go" 7 | ) 8 | 9 | // MockGenerator represents a mock metric generator 10 | type MockGenerator struct { 11 | values Values 12 | errValues error 13 | graphDefs []*mackerel.GraphDefsParam 14 | errGraphDefs error 15 | } 16 | 17 | // NewMockGenerator creates a new mock metric generator 18 | func NewMockGenerator(values Values, errValues error, graphDefs []*mackerel.GraphDefsParam, errGraphDefs error) Generator { 19 | return &MockGenerator{values, errValues, graphDefs, errGraphDefs} 20 | } 21 | 22 | // Generate generates metric values 23 | func (g *MockGenerator) Generate(context.Context) (Values, error) { 24 | return g.values, g.errValues 25 | } 26 | 27 | // GetGraphDefs gets graph definitions 28 | func (g *MockGenerator) GetGraphDefs(context.Context) ([]*mackerel.GraphDefsParam, error) { 29 | return g.graphDefs, g.errGraphDefs 30 | } 31 | -------------------------------------------------------------------------------- /internal/amazon-ecs-agent/agent/containermetadata/types.go: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"). You may 4 | // not use this file except in compliance with the License. A copy of the 5 | // License is located at 6 | // 7 | // http://aws.amazon.com/apache2.0/ 8 | // 9 | // or in the "license" file accompanying this file. This file is distributed 10 | // on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either 11 | // express or implied. See the License for the specific language governing 12 | // permissions and limitations under the License. 13 | 14 | package types 15 | 16 | // Network is a struct that keeps track of metadata of a network interface 17 | type Network struct { 18 | NetworkMode string `json:"NetworkMode,omitempty"` 19 | IPv4Addresses []string `json:"IPv4Addresses,omitempty"` 20 | IPv6Addresses []string `json:"IPv6Addresses,omitempty"` 21 | } 22 | -------------------------------------------------------------------------------- /check/mock_generator.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "context" 5 | 6 | mackerel "github.com/mackerelio/mackerel-client-go" 7 | ) 8 | 9 | // MockGenerator represents a mock check generator 10 | type MockGenerator struct { 11 | name string 12 | memo string 13 | results []*Result 14 | err error 15 | } 16 | 17 | // NewMockGenerator creates a new mock check generator 18 | func NewMockGenerator(name string, memo string, results []*Result, err error) Generator { 19 | return &MockGenerator{name: name, memo: memo, results: results, err: err} 20 | } 21 | 22 | // Config gets check generator config 23 | func (g *MockGenerator) Config() mackerel.CheckConfig { 24 | return mackerel.CheckConfig{Name: g.name, Memo: g.memo} 25 | } 26 | 27 | // Generate generates check report 28 | func (g *MockGenerator) Generate(context.Context) (*Result, error) { 29 | if len(g.results) == 0 { 30 | return nil, g.err 31 | } 32 | r := g.results[0] 33 | g.results = g.results[1:] 34 | return r, g.err 35 | } 36 | -------------------------------------------------------------------------------- /probe/tcp.go: -------------------------------------------------------------------------------- 1 | package probe 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "time" 8 | 9 | "github.com/mackerelio/mackerel-container-agent/config" 10 | ) 11 | 12 | var ( 13 | defaultTimeoutTCP = 1 * time.Second 14 | ) 15 | 16 | type probeTCP struct { 17 | *config.ProbeTCP 18 | initialDelay time.Duration 19 | period time.Duration 20 | timeout time.Duration 21 | } 22 | 23 | func (p *probeTCP) Check(ctx context.Context) error { 24 | timeout := p.timeout 25 | if timeout == 0 { 26 | timeout = defaultTimeoutTCP 27 | } 28 | 29 | addr := net.JoinHostPort(p.Host, p.Port) 30 | d := net.Dialer{Timeout: timeout} 31 | conn, err := d.DialContext(ctx, "tcp", addr) 32 | if err != nil { 33 | return fmt.Errorf("tcp probe failed (%s): %w", addr, err) 34 | } 35 | defer conn.Close() // nolint 36 | 37 | logger.Infof("tcp probe success (%s)", addr) 38 | return nil 39 | } 40 | 41 | func (p *probeTCP) InitialDelay() time.Duration { 42 | return p.initialDelay 43 | } 44 | 45 | func (p *probeTCP) Period() time.Duration { 46 | return p.period 47 | } 48 | -------------------------------------------------------------------------------- /check/collector.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | mackerel "github.com/mackerelio/mackerel-client-go" 8 | ) 9 | 10 | type collector struct { 11 | generators []Generator 12 | } 13 | 14 | func newCollector(generators []Generator) *collector { 15 | return &collector{ 16 | generators: generators, 17 | } 18 | } 19 | 20 | func (c *collector) configs() []mackerel.CheckConfig { 21 | configs := make([]mackerel.CheckConfig, len(c.generators)) 22 | for i, g := range c.generators { 23 | configs[i] = g.Config() 24 | } 25 | return configs 26 | } 27 | 28 | func (c *collector) collect(ctx context.Context) []*Result { 29 | var wg sync.WaitGroup 30 | reports := make([]*Result, 0, len(c.generators)) 31 | mu := new(sync.Mutex) 32 | for _, g := range c.generators { 33 | wg.Add(1) 34 | go func(g Generator) { 35 | defer wg.Done() 36 | r, err := g.Generate(ctx) 37 | if err != nil { 38 | logger.Errorf("%s", err) 39 | return 40 | } 41 | mu.Lock() 42 | defer mu.Unlock() 43 | if r != nil { 44 | reports = append(reports, r) 45 | } 46 | }(g) 47 | } 48 | wg.Wait() 49 | return reports 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mackerel-container-agent 2 | 3 | ![container-agent-si](docs/images/container-agent-si.png "mackerel-container-agent") 4 | 5 | This is a monitoring agent of [Mackerel](https://mackerel.io/) for containers on container orchestration platforms. 6 | Please use [mackerel-agent](https://github.com/mackerelio/mackerel-agent) for non-container environment. 7 | 8 | ## Features, usage and supported environments 9 | 10 | Please refer to [Monitoring Containers](https://mackerel.io/docs/entry/howto/container-agent). 11 | 12 | ## LICENSE 13 | 14 | ```plaintext 15 | Copyright 2018 Hatena Co., Ltd. 16 | 17 | Licensed under the Apache License, Version 2.0 (the "License"); 18 | you may not use this file except in compliance with the License. 19 | You may obtain a copy of the License at 20 | 21 | http://www.apache.org/licenses/LICENSE-2.0 22 | 23 | Unless required by applicable law or agreed to in writing, software 24 | distributed under the License is distributed on an "AS IS" BASIS, 25 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 26 | See the License for the specific language governing permissions and 27 | limitations under the License. 28 | ``` 29 | -------------------------------------------------------------------------------- /config/env.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | ) 8 | 9 | // Env represents environment variables 10 | type Env []string 11 | 12 | // UnmarshalYAML defines unmarshaler from YAML 13 | func (env *Env) UnmarshalYAML(unmarshal func(v any) error) (err error) { 14 | var envMap map[string]string 15 | if err = unmarshal(&envMap); err != nil { 16 | return err 17 | } 18 | if *env, err = buildEnv(envMap); err != nil { 19 | return err 20 | } 21 | return nil 22 | } 23 | 24 | func buildEnv(envMap map[string]string) ([]string, error) { 25 | if len(envMap) == 0 { 26 | return nil, nil 27 | } 28 | env := make([]string, 0, len(envMap)) 29 | for k, v := range envMap { 30 | if strings.Contains(k, "=") { 31 | return nil, fmt.Errorf("key of env should not contain \"=\", but got %q", k) 32 | } 33 | k = strings.Trim(k, " ") 34 | if k == "" { 35 | continue 36 | } 37 | env = append(env, k+"="+v) 38 | } 39 | sort.Strings(env) 40 | return env, nil 41 | } 42 | 43 | // MaskEnvValue return masked env value ex) FOOBARBAZ -> FOOB*** 44 | func MaskEnvValue(s string) string { 45 | if len(s) < 4 { 46 | return s 47 | } else { 48 | return s[:4] + "***" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/amazon-ecs-agent/agent/handlers/v1/response.go: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"). You may 4 | // not use this file except in compliance with the License. A copy of the 5 | // License is located at 6 | // 7 | // http://aws.amazon.com/apache2.0/ 8 | // 9 | // or in the "license" file accompanying this file. This file is distributed 10 | // on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either 11 | // express or implied. See the License for the specific language governing 12 | // permissions and limitations under the License. 13 | 14 | package v1 15 | 16 | // VolumeResponse is the schema for the volume response JSON object 17 | type VolumeResponse struct { 18 | DockerName string `json:"DockerName,omitempty"` 19 | Source string `json:"Source,omitempty"` 20 | Destination string `json:"Destination,omitempty"` 21 | } 22 | 23 | // PortResponse defines the schema for portmapping response JSON 24 | // object. 25 | type PortResponse struct { 26 | ContainerPort uint16 `json:"ContainerPort,omitempty"` 27 | Protocol string `json:"Protocol,omitempty"` 28 | HostPort uint16 `json:"HostPort,omitempty"` 29 | } 30 | -------------------------------------------------------------------------------- /probe/tcp_test.go: -------------------------------------------------------------------------------- 1 | package probe 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | "testing" 8 | "time" 9 | 10 | "github.com/mackerelio/mackerel-container-agent/config" 11 | ) 12 | 13 | func init() { 14 | defaultTimeoutTCP = 100 * time.Millisecond 15 | } 16 | 17 | func TestProbeTCP_Check(t *testing.T) { 18 | testCases := []struct { 19 | name string 20 | port string 21 | shouldErr bool 22 | }{ 23 | { 24 | name: "ok", 25 | }, 26 | { 27 | name: "invalid port", 28 | port: "1", 29 | shouldErr: true, 30 | }, 31 | } 32 | 33 | for _, tc := range testCases { 34 | t.Run(tc.name, func(t *testing.T) { 35 | ts := newHTTPServer(t, "ok", nil, "GET", "/", 0, http.StatusOK) 36 | u, _ := url.Parse(ts.URL) 37 | 38 | port := u.Port() 39 | if tc.port != "" { 40 | port = tc.port 41 | } 42 | p := NewProbe(&config.Probe{ 43 | TCP: &config.ProbeTCP{ 44 | Host: u.Hostname(), 45 | Port: port, 46 | }, 47 | }) 48 | err := p.Check(context.Background()) 49 | 50 | if err != nil && !tc.shouldErr { 51 | t.Errorf("should not raise error: %v", err) 52 | } 53 | if err == nil && tc.shouldErr { 54 | t.Errorf("should raise error: %v", err) 55 | } 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /probe/exec.go: -------------------------------------------------------------------------------- 1 | package probe 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/mackerelio/mackerel-container-agent/cmdutil" 9 | "github.com/mackerelio/mackerel-container-agent/config" 10 | ) 11 | 12 | var ( 13 | defaultTimeoutExec = 1 * time.Second 14 | ) 15 | 16 | type probeExec struct { 17 | *config.ProbeExec 18 | initialDelay time.Duration 19 | period time.Duration 20 | timeout time.Duration 21 | } 22 | 23 | func (p *probeExec) Check(ctx context.Context) error { 24 | timeout := p.timeout 25 | if timeout == 0 { 26 | timeout = defaultTimeoutExec 27 | } 28 | _, stderr, exitCode, err := cmdutil.RunCommand(ctx, p.Command, p.User, p.Env, timeout) 29 | 30 | if stderr != "" { 31 | stderr = fmt.Sprintf(", stderr = %q", stderr) 32 | } 33 | if err != nil { 34 | return fmt.Errorf("exec probe failed (%s): %s%s", p.Command, err, stderr) 35 | } 36 | if exitCode != 0 { 37 | return fmt.Errorf("exec probe failed (%s): exit code = %d%s", p.Command, exitCode, stderr) 38 | } 39 | 40 | logger.Infof("exec probe success (%s): exit code = %d", p.Command, exitCode) 41 | return nil 42 | } 43 | 44 | func (p *probeExec) InitialDelay() time.Duration { 45 | return p.initialDelay 46 | } 47 | 48 | func (p *probeExec) Period() time.Duration { 49 | return p.period 50 | } 51 | -------------------------------------------------------------------------------- /spec/cpu.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/mackerelio/golib/logging" 9 | mackerel "github.com/mackerelio/mackerel-client-go" 10 | "github.com/shirou/gopsutil/v3/cpu" 11 | ) 12 | 13 | // CPUGenerator collects CPU specs 14 | type CPUGenerator struct { 15 | } 16 | 17 | var cpuLogger = logging.GetLogger("spec.cpu") 18 | 19 | // Generate CPU specs 20 | func (g *CPUGenerator) Generate(ctx context.Context) (any, error) { 21 | infoStats, err := cpu.Info() 22 | if err != nil { 23 | cpuLogger.Errorf("Failed (skip this spec): %s", err) 24 | return nil, err 25 | } 26 | results := make(mackerel.CPU, 0, len(infoStats)) 27 | for _, infoStat := range infoStats { 28 | result := map[string]any{ 29 | "vendor_id": infoStat.VendorID, 30 | "model": infoStat.Model, 31 | "stepping": strconv.Itoa(int(infoStat.Stepping)), 32 | "physical_id": infoStat.PhysicalID, 33 | "core_id": infoStat.CoreID, 34 | "cache_size": fmt.Sprintf("%d KB", infoStat.CacheSize), 35 | "model_name": infoStat.ModelName, 36 | "family": infoStat.Family, 37 | "cores": strconv.Itoa(int(infoStat.Cores)), 38 | "mhz": strconv.FormatFloat(infoStat.Mhz, 'f', -1, 64), 39 | } 40 | results = append(results, result) 41 | } 42 | 43 | return results, nil 44 | } 45 | -------------------------------------------------------------------------------- /metric/manager_test.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/mackerelio/mackerel-container-agent/api" 9 | ) 10 | 11 | func TestManagerRun(t *testing.T) { 12 | client := api.NewMockClient() 13 | hostID := "abcde" 14 | manager := NewManager(createMockGenerators(), client) 15 | 16 | ctx, cancel := context.WithTimeout(context.Background(), 190*time.Millisecond) 17 | defer cancel() 18 | go func() { 19 | time.Sleep(50 * time.Millisecond) 20 | if err := manager.CollectAndPostGraphDefs(ctx); err != nil { 21 | t.Errorf("err should be nil but got: %+v", err) 22 | } 23 | manager.SetHostID(hostID) 24 | }() 25 | err := manager.Run(ctx, 40*time.Millisecond) 26 | if err != nil { 27 | t.Errorf("err should be nil but got: %+v", err) 28 | } 29 | metricValues := client.PostedMetricValues() 30 | 31 | // This test is flaky so we should check the count with an accuracy. 32 | const ( 33 | metricNum = 9 34 | expected = 4 * metricNum 35 | expectedMin = 1 * metricNum 36 | ) 37 | if n := len(metricValues[hostID]); n < expectedMin || n > expected { 38 | t.Errorf("metric values should have size %d but got: %d", expected, n) 39 | } 40 | graphDefs := client.PostedGraphDefs() 41 | if expected := 2; len(graphDefs) != expected { 42 | t.Errorf("graph definitions should have size %d but got: %#v", expected, graphDefs) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /internal/amazon-ecs-agent/agent/api/container/container.go: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"). You may 4 | // not use this file except in compliance with the License. A copy of the 5 | // License is located at 6 | // 7 | // http://aws.amazon.com/apache2.0/ 8 | // 9 | // or in the "license" file accompanying this file. This file is distributed 10 | // on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either 11 | // express or implied. See the License for the specific language governing 12 | // permissions and limitations under the License. 13 | 14 | package container 15 | 16 | import ( 17 | "time" 18 | 19 | apicontainerstatus "github.com/mackerelio/mackerel-container-agent/internal/amazon-ecs-agent/agent/api/container/status" 20 | ) 21 | 22 | // HealthStatus contains the health check result returned by docker 23 | type HealthStatus struct { 24 | // Status is the container health status 25 | Status apicontainerstatus.ContainerHealthStatus `json:"status,omitempty"` 26 | // Since is the timestamp when container health status changed 27 | Since *time.Time `json:"statusSince,omitempty"` 28 | // ExitCode is the exitcode of health check if failed 29 | ExitCode int `json:"exitCode,omitempty"` 30 | // Output is the output of health check 31 | Output string `json:"output,omitempty"` 32 | } 33 | -------------------------------------------------------------------------------- /cmdutil/cmdutil.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "os" 8 | "os/exec" 9 | "time" 10 | 11 | "github.com/Songmu/timeout" 12 | ) 13 | 14 | var ( 15 | defaultTimeoutDuration = 30 * time.Second 16 | timeoutKillAfter = 10 * time.Second 17 | errTimedOut = errors.New("command timed out") 18 | ) 19 | 20 | // RunCommand executes command with context 21 | func RunCommand(ctx context.Context, command Command, user string, env []string, timeoutDuration time.Duration) (string, string, int, error) { 22 | args := command.ToArgs() 23 | if user != "" { 24 | args = append([]string{"sudo", "-Eu", user}, args...) 25 | } 26 | cmd := exec.Command(args[0], args[1:]...) 27 | cmd.Env = append(os.Environ(), env...) 28 | outbuf, errbuf := new(bytes.Buffer), new(bytes.Buffer) 29 | cmd.Stdout, cmd.Stderr = outbuf, errbuf 30 | tio := &timeout.Timeout{ 31 | Cmd: cmd, 32 | Duration: defaultTimeoutDuration, 33 | KillAfter: timeoutKillAfter, 34 | } 35 | if timeoutDuration > 0 { 36 | tio.Duration = timeoutDuration 37 | } 38 | exitStatus, err := tio.RunContext(ctx) 39 | exitCode := -1 40 | if err != nil { 41 | if terr, ok := err.(*timeout.Error); ok { 42 | exitCode = terr.ExitCode 43 | } 44 | } else { 45 | exitCode = exitStatus.GetChildExitCode() 46 | if exitStatus.IsTimedOut() && exitStatus.Signaled { 47 | err = errTimedOut 48 | } 49 | } 50 | return outbuf.String(), errbuf.String(), exitCode, err 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/create-release-pr.yml: -------------------------------------------------------------------------------- 1 | name: "Create Release PR" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | release_version: 6 | description: "next release version" 7 | required: true 8 | env: 9 | GIT_AUTHOR_NAME: mackerelbot 10 | GIT_AUTHOR_EMAIL: mackerelbot@users.noreply.github.com 11 | GIT_COMMITTER_NAME: mackerelbot 12 | GIT_COMMITTER_EMAIL: mackerelbot@users.noreply.github.com 13 | 14 | jobs: 15 | create: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 19 | 20 | - uses: mackerelio/mackerel-create-release-pull-request-action@c30e6538510d81ac7acb49cbadd7a6d15162006d # v0.0.1 21 | id: start 22 | with: 23 | github_token: ${{ secrets.MACKERELBOT_GITHUB_TOKEN }} 24 | next_version: ${{ github.event.inputs.release_version }} 25 | package_name: mackerel-container-agent 26 | ignore_update_program_files: "true" 27 | 28 | - uses: mackerelio/mackerel-create-release-pull-request-action@c30e6538510d81ac7acb49cbadd7a6d15162006d # v0.0.1 29 | with: 30 | github_token: ${{ secrets.MACKERELBOT_GITHUB_TOKEN }} 31 | finished: "true" 32 | package_name: mackerel-container-agent 33 | next_version: ${{ steps.start.outputs.nextVersion }} 34 | branch_name: ${{ steps.start.outputs.branchName }} 35 | pull_request_infos: ${{ steps.start.outputs.pullRequestInfos }} 36 | -------------------------------------------------------------------------------- /metric/collector.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | mackerel "github.com/mackerelio/mackerel-client-go" 8 | ) 9 | 10 | type collector struct { 11 | generators []Generator 12 | } 13 | 14 | func newCollector(generators []Generator) *collector { 15 | return &collector{ 16 | generators: generators, 17 | } 18 | } 19 | 20 | func (c *collector) collect(ctx context.Context) (Values, error) { 21 | var wg sync.WaitGroup 22 | values := make(Values) 23 | mu := new(sync.Mutex) 24 | for _, g := range c.generators { 25 | wg.Add(1) 26 | go func(g Generator) { 27 | defer wg.Done() 28 | vs, err := g.Generate(ctx) 29 | if err != nil { 30 | logger.Errorf("%s", err) 31 | return 32 | } 33 | mu.Lock() 34 | defer mu.Unlock() 35 | for key, value := range vs { 36 | values[key] = value 37 | } 38 | }(g) 39 | } 40 | wg.Wait() 41 | return values, nil 42 | } 43 | 44 | func (c *collector) collectGraphDefs(ctx context.Context) ([]*mackerel.GraphDefsParam, error) { 45 | var wg sync.WaitGroup 46 | var graphDefs []*mackerel.GraphDefsParam 47 | mu := new(sync.Mutex) 48 | for _, g := range c.generators { 49 | wg.Add(1) 50 | go func(g Generator) { 51 | defer wg.Done() 52 | gs, err := g.GetGraphDefs(ctx) 53 | if err != nil { 54 | logger.Errorf("%s", err) 55 | return 56 | } 57 | mu.Lock() 58 | defer mu.Unlock() 59 | graphDefs = append(graphDefs, gs...) 60 | }(g) 61 | } 62 | wg.Wait() 63 | return graphDefs, nil 64 | } 65 | -------------------------------------------------------------------------------- /config/loader.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "time" 7 | 8 | "github.com/mackerelio/golib/logging" 9 | ) 10 | 11 | var logger = logging.GetLogger("config") 12 | 13 | // Loader represents a config loader 14 | type Loader struct { 15 | location string 16 | pollingDuration time.Duration 17 | lastConfig *Config 18 | } 19 | 20 | // NewLoader creates a new Loader 21 | func NewLoader(location string, pollingDuration time.Duration) *Loader { 22 | return &Loader{location: location, pollingDuration: pollingDuration} 23 | } 24 | 25 | // Load agent configuration 26 | func (l *Loader) Load(ctx context.Context) (*Config, error) { 27 | config, err := load(ctx, l.location) 28 | if err != nil { 29 | return nil, err 30 | } 31 | l.lastConfig = config 32 | return config, nil 33 | } 34 | 35 | // Start the loader loop 36 | func (l *Loader) Start(ctx context.Context) <-chan struct{} { 37 | ch := make(chan struct{}) 38 | go func() { 39 | defer close(ch) 40 | if l.pollingDuration > 0 { 41 | t := time.NewTicker(l.pollingDuration) 42 | defer t.Stop() 43 | for { 44 | select { 45 | case <-ctx.Done(): 46 | return 47 | case <-t.C: 48 | config, err := load(ctx, l.location) 49 | if err != nil { 50 | logger.Warningf("failed to load config: %s", err) 51 | } else if !reflect.DeepEqual(l.lastConfig, config) { 52 | logger.Infof("detected config changes") 53 | return 54 | } 55 | } 56 | } 57 | } else { 58 | <-ctx.Done() 59 | } 60 | }() 61 | return ch 62 | } 63 | -------------------------------------------------------------------------------- /probe/exec_test.go: -------------------------------------------------------------------------------- 1 | package probe 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/mackerelio/mackerel-container-agent/cmdutil" 9 | "github.com/mackerelio/mackerel-container-agent/config" 10 | ) 11 | 12 | func init() { 13 | defaultTimeoutExec = 100 * time.Millisecond 14 | } 15 | 16 | func TestProbeExec_Check(t *testing.T) { 17 | testCases := []struct { 18 | name string 19 | command string 20 | timeoutSeconds int 21 | env []string 22 | shouldErr bool 23 | }{ 24 | { 25 | name: "ok", 26 | command: "echo ok", 27 | }, 28 | { 29 | name: "exit 0", 30 | command: "exit 0", 31 | }, 32 | { 33 | name: "exit 1", 34 | command: "exit 1", 35 | shouldErr: true, 36 | }, 37 | { 38 | name: "timeout", 39 | command: "sleep 5", 40 | shouldErr: true, 41 | }, 42 | { 43 | name: "timeout seconds", 44 | command: "sleep 0.5", 45 | timeoutSeconds: 1, 46 | }, 47 | } 48 | 49 | for _, tc := range testCases { 50 | t.Run(tc.name, func(t *testing.T) { 51 | p := NewProbe(&config.Probe{ 52 | Exec: &config.ProbeExec{ 53 | Command: cmdutil.CommandString(tc.command), 54 | Env: tc.env, 55 | }, 56 | TimeoutSeconds: tc.timeoutSeconds, 57 | }) 58 | err := p.Check(context.Background()) 59 | 60 | if err != nil && !tc.shouldErr { 61 | t.Errorf("should not raise error: %v", err) 62 | } 63 | if err == nil && tc.shouldErr { 64 | t.Errorf("should raise error: %v", err) 65 | } 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /config/s3.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | 9 | "github.com/aws/aws-sdk-go-v2/aws" 10 | "github.com/aws/aws-sdk-go-v2/config" 11 | "github.com/aws/aws-sdk-go-v2/feature/s3/manager" 12 | "github.com/aws/aws-sdk-go-v2/service/s3" 13 | ) 14 | 15 | type downloader interface { 16 | download(context.Context, *url.URL) ([]byte, error) 17 | } 18 | 19 | type s3Downloader struct { 20 | regionHint string 21 | } 22 | 23 | func (d s3Downloader) download(ctx context.Context, u *url.URL) ([]byte, error) { 24 | var ( 25 | bucket = u.Host 26 | key = strings.TrimPrefix(u.Path, "/") 27 | ) 28 | 29 | cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(d.regionHint)) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | region, err := manager.GetBucketRegion(ctx, s3.NewFromConfig(cfg), bucket) 35 | if err != nil { 36 | return nil, fmt.Errorf("failed to get bucket region for %s: %w", bucket, err) 37 | } 38 | cfg.Region = region 39 | 40 | downloader := manager.NewDownloader(s3.NewFromConfig(cfg)) 41 | 42 | buf := manager.NewWriteAtBuffer([]byte{}) 43 | _, err = downloader.Download(ctx, buf, &s3.GetObjectInput{ 44 | Bucket: aws.String(bucket), 45 | Key: aws.String(key), 46 | }) 47 | if err != nil { 48 | return nil, fmt.Errorf("failed to download config from %s: %w", u, err) 49 | } 50 | 51 | return buf.Bytes(), nil 52 | } 53 | 54 | var s3downloader downloader = s3Downloader{ 55 | regionHint: "ap-northeast-1", 56 | } 57 | 58 | func fetchS3(ctx context.Context, u *url.URL) ([]byte, error) { 59 | return s3downloader.download(ctx, u) 60 | } 61 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: docker 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "01:00" 8 | timezone: Asia/Tokyo 9 | open-pull-requests-limit: 10 10 | cooldown: 11 | default-days: 5 12 | include: 13 | - "*" 14 | - package-ecosystem: gomod 15 | directory: "/" 16 | schedule: 17 | interval: daily 18 | time: "01:00" 19 | timezone: Asia/Tokyo 20 | open-pull-requests-limit: 10 21 | groups: 22 | aws/aws-sdk-go-v2: 23 | patterns: 24 | - "github.com/aws/aws-sdk-go-v2" 25 | - "github.com/aws/aws-sdk-go-v2/*" 26 | mackerelio: 27 | patterns: 28 | - "github.com/mackerelio/*" 29 | k8s: 30 | patterns: 31 | - "k8s.io/*" 32 | cooldown: 33 | default-days: 5 34 | include: 35 | - "*" 36 | exclude: 37 | - "github.com/mackerelio/*" 38 | - "github.com/aws/aws-sdk-go-v2" 39 | - "github.com/aws/aws-sdk-go-v2/*" 40 | - package-ecosystem: gomod 41 | directory: "/plugins" 42 | schedule: 43 | interval: daily 44 | time: "17:00" 45 | timezone: Asia/Tokyo 46 | open-pull-requests-limit: 10 47 | groups: 48 | mackerelio: 49 | patterns: 50 | - "github.com/mackerelio/*" 51 | cooldown: 52 | default-days: 5 53 | include: 54 | - "*" 55 | exclude: 56 | - "github.com/mackerelio/*" 57 | - package-ecosystem: github-actions 58 | directory: "/" 59 | schedule: 60 | interval: daily 61 | time: "01:00" 62 | timezone: Asia/Tokyo 63 | cooldown: 64 | default-days: 5 65 | include: 66 | - "*" 67 | -------------------------------------------------------------------------------- /platform/ecs/internal/mock_taskmetadatagetter.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | ecsTypes "github.com/mackerelio/mackerel-container-agent/internal/amazon-ecs-agent/agent/handlers/v2" 8 | ) 9 | 10 | // MockTaskMetadataGetter is a mock of /task API endpoint 11 | type MockTaskMetadataGetter struct { 12 | getTaskMetadataCallback func(context.Context) (*ecsTypes.TaskResponse, error) 13 | } 14 | 15 | // MockTaskMetadataGetterOption represents an option of mock client of /task API endpoint 16 | type MockTaskMetadataGetterOption func(*MockTaskMetadataGetter) 17 | 18 | // NewMockTaskMetadataGetter creates a new mock of /task API endpoint 19 | func NewMockTaskMetadataGetter(opts ...MockTaskMetadataGetterOption) *MockTaskMetadataGetter { 20 | g := &MockTaskMetadataGetter{} 21 | for _, o := range opts { 22 | g.ApplyOption(o) 23 | } 24 | return g 25 | } 26 | 27 | // ApplyOption applies a mock option 28 | func (g *MockTaskMetadataGetter) ApplyOption(opt MockTaskMetadataGetterOption) { 29 | opt(g) 30 | } 31 | 32 | // GetTaskMetadata returns /task API response 33 | func (g *MockTaskMetadataGetter) GetTaskMetadata(ctx context.Context) (*ecsTypes.TaskResponse, error) { 34 | if g.getTaskMetadataCallback != nil { 35 | return g.getTaskMetadataCallback(ctx) 36 | } 37 | return nil, errors.New("MockGetTaskMetadata not found") 38 | } 39 | 40 | // MockGetTaskMetadata returns an option to set the callback of GetTaskMetadata 41 | func MockGetTaskMetadata(callback func(context.Context) (*ecsTypes.TaskResponse, error)) MockTaskMetadataGetterOption { 42 | return func(g *MockTaskMetadataGetter) { 43 | g.getTaskMetadataCallback = callback 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /spec/interface.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "net" 5 | 6 | mackerel "github.com/mackerelio/mackerel-client-go" 7 | ) 8 | 9 | func getInterfaces() ([]mackerel.Interface, error) { 10 | 11 | ifaces, err := net.Interfaces() 12 | if err != nil { 13 | return nil, err 14 | } 15 | 16 | values := make([]mackerel.Interface, 0, len(ifaces)) 17 | 18 | for _, iface := range ifaces { 19 | if iface.Flags&net.FlagLoopback != 0 { 20 | continue 21 | } 22 | if iface.HardwareAddr == nil { 23 | continue 24 | } 25 | 26 | addrs, err := iface.Addrs() 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | ipv4, ipv6 := distinguishAddress(addrs) 32 | if len(ipv4) == 0 && len(ipv6) == 0 { 33 | continue 34 | } 35 | 36 | var ipaddr string 37 | if len(ipv4) > 0 { 38 | ipaddr = ipv4[0] 39 | } else if len(ipv6) > 0 { 40 | ipaddr = ipv6[0] 41 | } 42 | 43 | values = append(values, mackerel.Interface{ 44 | Name: iface.Name, 45 | MacAddress: iface.HardwareAddr.String(), 46 | IPv4Addresses: ipv4, 47 | IPv6Addresses: ipv6, 48 | IPAddress: ipaddr, 49 | }) 50 | } 51 | 52 | return values, nil 53 | } 54 | 55 | func distinguishAddress(addrs []net.Addr) (ipv4 []string, ipv6 []string) { 56 | for _, addr := range addrs { 57 | var ip net.IP 58 | switch addr := addr.(type) { 59 | case *net.IPNet: 60 | ip = addr.IP 61 | case *net.IPAddr: 62 | ip = addr.IP 63 | default: 64 | continue 65 | } 66 | if ip == nil { 67 | continue 68 | } 69 | if ip.To4() != nil { 70 | ipv4 = append(ipv4, ip.String()) 71 | } else if len(ip) == net.IPv6len && ip.To4() == nil { 72 | ipv6 = append(ipv6, ip.String()) 73 | } 74 | } 75 | return 76 | } 77 | -------------------------------------------------------------------------------- /check/sender.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "sync" 5 | 6 | mackerel "github.com/mackerelio/mackerel-client-go" 7 | 8 | "github.com/mackerelio/mackerel-container-agent/api" 9 | ) 10 | 11 | const maxPendingReports = 60 12 | 13 | type sender struct { 14 | client api.Client 15 | hostID string 16 | pendingReports [][]*mackerel.CheckReport 17 | mu sync.Mutex 18 | } 19 | 20 | func newSender(client api.Client) *sender { 21 | return &sender{client: client} 22 | } 23 | 24 | func (s *sender) post(reports []*mackerel.CheckReport) error { 25 | s.mu.Lock() 26 | defer s.mu.Unlock() 27 | s.pendingReports = append(s.pendingReports, reports) 28 | if s.hostID == "" { 29 | return nil 30 | } 31 | var postReports []*mackerel.CheckReport 32 | var postIndex int 33 | for i, r := range s.pendingReports { 34 | postIndex = i 35 | postReports = append(postReports, r...) 36 | if i > 1 { 37 | break 38 | } 39 | } 40 | for _, r := range postReports { 41 | r.Source = mackerel.NewCheckSourceHost(s.hostID) 42 | } 43 | var err error 44 | if len(postReports) > 0 { 45 | err = s.client.PostCheckReports(&mackerel.CheckReports{Reports: postReports}) 46 | } 47 | if err == nil { 48 | n := copy(s.pendingReports, s.pendingReports[postIndex+1:]) 49 | s.pendingReports = s.pendingReports[:n] 50 | } else { 51 | logger.Warningf("failed to post check monitoring reports but will retry posting: %s", err) 52 | } 53 | if len(s.pendingReports) > maxPendingReports { 54 | n := copy(s.pendingReports, s.pendingReports[len(s.pendingReports)-maxPendingReports:]) 55 | s.pendingReports = s.pendingReports[:n] 56 | } 57 | return nil 58 | } 59 | 60 | func (s *sender) setHostID(hostID string) { 61 | s.mu.Lock() 62 | defer s.mu.Unlock() 63 | s.hostID = hostID 64 | } 65 | -------------------------------------------------------------------------------- /platform/ecs/taskmetadata/testdata/metadata_ec2_host.json: -------------------------------------------------------------------------------- 1 | { 2 | "Cluster": "test-clusrer", 3 | "TaskARN": "arn:aws:ecs:ap-northeast-1:999999999999:task/task-id", 4 | "Family": "test-ec2-host", 5 | "Revision": "1", 6 | "DesiredStatus": "RUNNING", 7 | "KnownStatus": "RUNNING", 8 | "Containers": [ 9 | { 10 | "DockerId": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 11 | "Name": "mackerel-container-agent", 12 | "DockerName": "ecs-test-cluster-1-mackerel-container-agent-ffffffffffffffffffff", 13 | "Image": "mackerel/mackerel-container-agent:latest", 14 | "ImageID": "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 15 | "Labels": { 16 | "com.amazonaws.ecs.cluster": "test-cluster", 17 | "com.amazonaws.ecs.container-name": "mackerel-container-agent", 18 | "com.amazonaws.ecs.task-arn": "arn:aws:ecs:ap-northeast-1:999999999999:task/task-id", 19 | "com.amazonaws.ecs.task-definition-family": "test-ec2-host", 20 | "com.amazonaws.ecs.task-definition-version": "1" 21 | }, 22 | "DesiredStatus": "RUNNING", 23 | "KnownStatus": "RUNNING", 24 | "Limits": { 25 | "CPU": 0, 26 | "Memory": 128 27 | }, 28 | "CreatedAt": "2019-03-29T02:54:57.61447652Z", 29 | "StartedAt": "2019-03-29T02:54:58.346799541Z", 30 | "Type": "NORMAL", 31 | "Networks": [ 32 | { 33 | "NetworkMode": "host", 34 | "IPv4Addresses": [ 35 | "" 36 | ] 37 | } 38 | ] 39 | } 40 | ], 41 | "Limits": { 42 | "CPU": 0.25, 43 | "Memory": 256 44 | }, 45 | "PullStartedAt": "2019-03-29T02:54:51.763717413Z", 46 | "PullStoppedAt": "2019-03-29T02:54:58.303055973Z", 47 | "AvailabilityZone": "ap-northeast-1a" 48 | } 49 | -------------------------------------------------------------------------------- /metric/sender.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "sync" 5 | 6 | mackerel "github.com/mackerelio/mackerel-client-go" 7 | 8 | "github.com/mackerelio/mackerel-container-agent/api" 9 | ) 10 | 11 | type sender struct { 12 | client api.Client 13 | hostID string 14 | pendingMetrics [][]*mackerel.MetricValue 15 | mu sync.Mutex 16 | } 17 | 18 | func newSender(client api.Client) *sender { 19 | return &sender{client: client} 20 | } 21 | 22 | func (s *sender) post(metricValues []*mackerel.MetricValue) error { 23 | s.mu.Lock() 24 | defer s.mu.Unlock() 25 | s.pendingMetrics = append(s.pendingMetrics, metricValues) 26 | if s.hostID == "" { 27 | return nil 28 | } 29 | var postMetricValues []*mackerel.MetricValue 30 | var postIndex int 31 | for i, ms := range s.pendingMetrics { 32 | postIndex = i 33 | postMetricValues = append(postMetricValues, ms...) 34 | if i > 1 { // send three oldest metrics at most 35 | break 36 | } 37 | } 38 | err := s.client.PostHostMetricValuesByHostID(s.hostID, postMetricValues) 39 | if err == nil { 40 | n := copy(s.pendingMetrics, s.pendingMetrics[postIndex+1:]) 41 | s.pendingMetrics = s.pendingMetrics[:n] 42 | } else { 43 | logger.Warningf("failed to post metric values but will retry posting: %s", err) 44 | } 45 | if len(s.pendingMetrics) > 60*6 { // retry for 6 hours 46 | n := copy(s.pendingMetrics, s.pendingMetrics[len(s.pendingMetrics)-60*6:]) 47 | s.pendingMetrics = s.pendingMetrics[:n] 48 | } 49 | return nil 50 | } 51 | 52 | func (s *sender) setHostID(hostID string) { 53 | s.mu.Lock() 54 | defer s.mu.Unlock() 55 | s.hostID = hostID 56 | } 57 | 58 | func (s *sender) postGraphDefs(graphDefs []*mackerel.GraphDefsParam) error { 59 | if len(graphDefs) == 0 { 60 | return nil 61 | } 62 | return s.client.CreateGraphDefs(graphDefs) 63 | } 64 | -------------------------------------------------------------------------------- /platform/ecs/taskmetadata/testdata/metadata_ec2_bridge.json: -------------------------------------------------------------------------------- 1 | { 2 | "Cluster": "test-clusrer", 3 | "TaskARN": "arn:aws:ecs:ap-northeast-1:999999999999:task/task-id", 4 | "Family": "test-ec2-bridge", 5 | "Revision": "1", 6 | "DesiredStatus": "RUNNING", 7 | "KnownStatus": "RUNNING", 8 | "Containers": [ 9 | { 10 | "DockerId": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 11 | "Name": "mackerel-container-agent", 12 | "DockerName": "ecs-test-cluster-1-mackerel-container-agent-ffffffffffffffffffff", 13 | "Image": "mackerel/mackerel-container-agent:latest", 14 | "ImageID": "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 15 | "Labels": { 16 | "com.amazonaws.ecs.cluster": "test-cluster", 17 | "com.amazonaws.ecs.container-name": "mackerel-container-agent", 18 | "com.amazonaws.ecs.task-arn": "arn:aws:ecs:ap-northeast-1:999999999999:task/task-id", 19 | "com.amazonaws.ecs.task-definition-family": "test-ec2-bridge", 20 | "com.amazonaws.ecs.task-definition-version": "1" 21 | }, 22 | "DesiredStatus": "RUNNING", 23 | "KnownStatus": "RUNNING", 24 | "Limits": { 25 | "CPU": 0, 26 | "Memory": 128 27 | }, 28 | "CreatedAt": "2019-03-29T02:54:57.61447652Z", 29 | "StartedAt": "2019-03-29T02:54:58.346799541Z", 30 | "Type": "NORMAL", 31 | "Networks": [ 32 | { 33 | "NetworkMode": "bridge", 34 | "IPv4Addresses": [ 35 | "172.17.0.2" 36 | ] 37 | } 38 | ] 39 | } 40 | ], 41 | "Limits": { 42 | "CPU": 0.25, 43 | "Memory": 256 44 | }, 45 | "PullStartedAt": "2019-03-29T02:54:51.763717413Z", 46 | "PullStoppedAt": "2019-03-29T02:54:58.303055973Z", 47 | "AvailabilityZone": "ap-northeast-1a" 48 | } 49 | -------------------------------------------------------------------------------- /cmdutil/command_test.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | var commandTestCases = []struct { 11 | name string 12 | src string 13 | toString string 14 | toArgs []string 15 | isEmpty bool 16 | }{ 17 | { 18 | name: "empty", 19 | src: `command:`, 20 | isEmpty: true, 21 | }, 22 | { 23 | name: "one line string", 24 | src: `command: echo hello`, 25 | toString: `echo hello`, 26 | toArgs: []string{"/bin/sh", "-c", "echo hello"}, 27 | isEmpty: false, 28 | }, 29 | { 30 | name: "one line slice of string", 31 | src: `command: [ "echo", "hello world" ]`, 32 | toString: `echo "hello world"`, 33 | toArgs: []string{"echo", "hello world"}, 34 | isEmpty: false, 35 | }, 36 | { 37 | name: "multi-line slice of string", 38 | src: `command: 39 | - echo 40 | - hello 41 | - world`, 42 | toString: `echo hello world`, 43 | toArgs: []string{"echo", "hello", "world"}, 44 | isEmpty: false, 45 | }, 46 | } 47 | 48 | func TestCommand(t *testing.T) { 49 | for _, tc := range commandTestCases { 50 | t.Run(tc.name, func(t *testing.T) { 51 | var conf struct { 52 | Command Command `yaml:"command"` 53 | } 54 | err := yaml.Unmarshal([]byte(tc.src), &conf) 55 | if err != nil { 56 | t.Fatalf("should not raise error: %v", err) 57 | } 58 | if got := conf.Command.IsEmpty(); got != tc.isEmpty { 59 | t.Errorf("IsEmpty(): expect %#v, got %#v", tc.isEmpty, got) 60 | } 61 | if !conf.Command.IsEmpty() { 62 | if s := conf.Command.String(); s != tc.toString { 63 | t.Errorf("String(): expect %#v, got %#v", tc.toString, s) 64 | } 65 | if got := conf.Command.ToArgs(); !reflect.DeepEqual(got, tc.toArgs) { 66 | t.Errorf("ToArgs(): expect %#v, got %#v", tc.toArgs, got) 67 | } 68 | } 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-bookworm AS builder 2 | 3 | WORKDIR /go/src/app 4 | 5 | COPY go.sum go.mod ./ 6 | RUN go mod download 7 | 8 | COPY . . 9 | RUN make build 10 | 11 | FROM debian:bookworm-slim AS container-agent 12 | 13 | ENV DEBIAN_FRONTEND=noninteractive 14 | ENV GODEBUG=http2client=0 15 | 16 | RUN apt-get update -yq && \ 17 | apt-get install -yq --no-install-recommends ca-certificates sudo && \ 18 | rm -rf /var/lib/apt/lists 19 | 20 | COPY --from=builder /go/src/app/build/mackerel-container-agent /usr/local/bin/ 21 | 22 | ENTRYPOINT ["/usr/local/bin/mackerel-container-agent"] 23 | 24 | FROM golang:1.24-bookworm AS plugins-builder 25 | 26 | COPY plugins/go.sum plugins/go.mod ./ 27 | RUN go mod download 28 | 29 | RUN go install \ 30 | github.com/mackerelio/go-check-plugins \ 31 | github.com/mackerelio/mackerel-agent-plugins \ 32 | github.com/mackerelio/mackerel-plugin-json \ 33 | github.com/mackerelio/mkr 34 | 35 | FROM container-agent AS container-agent-with-plugins 36 | 37 | # for compat. deb packages installed path. 38 | COPY --from=plugins-builder /go/bin/go-check-plugins /usr/bin/mackerel-check 39 | COPY --from=plugins-builder /go/bin/mackerel-agent-plugins /usr/bin/mackerel-plugin 40 | COPY --from=plugins-builder /go/bin/mackerel-plugin-json /opt/mackerel-agent/plugins/bin/mackerel-plugin-json 41 | COPY --from=plugins-builder /go/bin/mkr /usr/bin/mkr 42 | 43 | ENV PATH=$PATH:/opt/mackerel-agent/plugins/bin 44 | 45 | RUN /bin/bash -c 'cd /usr/bin; for i in apache2 elasticsearch fluentd gostats haproxy jmx-jolokia memcached mysql nginx php-apc php-fpm php-opcache plack postgres redis sidekiq snmp squid uwsgi-vassal;do ln -s ./mackerel-plugin mackerel-plugin-$i; done' 46 | RUN /bin/bash -c 'cd /usr/bin; for i in cert-file elasticsearch file-age file-size http jmx-jolokia log memcached mysql postgresql redis ssh ssl-cert tcp;do ln -s ./mackerel-check check-$i; done' 47 | -------------------------------------------------------------------------------- /platform/ecs/taskmetadata/testdata/metadata_ecs_anywhere.json: -------------------------------------------------------------------------------- 1 | { 2 | "Cluster": "test-clusrer", 3 | "TaskARN": "arn:aws:ecs:ap-northeast-1:999999999999:task/task-id", 4 | "Family": "test-external", 5 | "Revision": "4", 6 | "DesiredStatus": "RUNNING", 7 | "KnownStatus": "RUNNING", 8 | "Limits": { 9 | "CPU": 0.25, 10 | "Memory": 256 11 | }, 12 | "PullStartedAt": "2022-03-14T06:44:39.928830311Z", 13 | "PullStoppedAt": "2022-03-14T06:44:42.387434211Z", 14 | "LaunchType": "EXTERNAL", 15 | "Containers": [ 16 | { 17 | "DockerId": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 18 | "Name": "mackerel-container-agent", 19 | "DockerName": "ecs-test-cluster-1-mackerel-container-agent-ffffffffffffffffffff", 20 | "Image": "mackerel/mackerel-container-agent:latest", 21 | "ImageID": "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 22 | "Labels": { 23 | "com.amazonaws.ecs.cluster": "test-cluster", 24 | "com.amazonaws.ecs.container-name": "mackerel-container-agent", 25 | "com.amazonaws.ecs.task-arn": "arn:aws:ecs:ap-northeast-1:999999999999:task/task-id", 26 | "com.amazonaws.ecs.task-definition-family": "test-fargate", 27 | "com.amazonaws.ecs.task-definition-version": "1" 28 | }, 29 | "DesiredStatus": "RUNNING", 30 | "KnownStatus": "RUNNING", 31 | "Limits": { 32 | "CPU": 0, 33 | "Memory": 128 34 | }, 35 | "CreatedAt": "2019-03-29T02:54:57.61447652Z", 36 | "StartedAt": "2019-03-29T02:54:58.346799541Z", 37 | "Type": "NORMAL", 38 | "Networks": [ 39 | { 40 | "NetworkMode": "awsvpc", 41 | "IPv4Addresses": [ 42 | "172.17.0.2" 43 | ] 44 | } 45 | ] 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /metric/interface.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "time" 7 | 8 | "github.com/mackerelio/go-osstat/network" 9 | mackerel "github.com/mackerelio/mackerel-client-go" 10 | ) 11 | 12 | type interfaceGenerator struct { 13 | prevStats map[string]network.Stats 14 | prevTime time.Time 15 | } 16 | 17 | // NewInterfaceGenerator creates interface generator 18 | func NewInterfaceGenerator() Generator { 19 | return &interfaceGenerator{} 20 | } 21 | 22 | func (g *interfaceGenerator) Generate(context.Context) (Values, error) { 23 | stats, err := g.getInterfaceStats() 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | now := time.Now() 29 | if g.prevStats == nil || g.prevTime.Before(now.Add(-10*time.Minute)) { 30 | g.prevStats = stats 31 | g.prevTime = now 32 | return nil, nil 33 | } 34 | 35 | values := make(Values) 36 | timeDelta := now.Sub(g.prevTime).Seconds() 37 | for name, prevValue := range g.prevStats { 38 | currValue, ok := stats[name] 39 | if !ok { 40 | continue 41 | } 42 | name = SanitizeMetricKey(name) 43 | prefix := "interface." + name 44 | values[prefix+".rxBytes.delta"] = float64(currValue.RxBytes-prevValue.RxBytes) / timeDelta 45 | values[prefix+".txBytes.delta"] = float64(currValue.TxBytes-prevValue.TxBytes) / timeDelta 46 | } 47 | 48 | g.prevStats = stats 49 | g.prevTime = now 50 | 51 | return values, nil 52 | } 53 | 54 | func (g *interfaceGenerator) getInterfaceStats() (map[string]network.Stats, error) { 55 | stats, err := network.Get() 56 | if err != nil { 57 | return nil, err 58 | } 59 | values := make(map[string]network.Stats) 60 | for _, s := range stats { 61 | if strings.HasPrefix(s.Name, "veth") { 62 | continue 63 | } 64 | values[s.Name] = s 65 | } 66 | return values, nil 67 | } 68 | 69 | func (g *interfaceGenerator) GetGraphDefs(context.Context) ([]*mackerel.GraphDefsParam, error) { 70 | return nil, nil 71 | } 72 | -------------------------------------------------------------------------------- /check/manager.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/mackerelio/golib/logging" 8 | mackerel "github.com/mackerelio/mackerel-client-go" 9 | 10 | "github.com/mackerelio/mackerel-container-agent/api" 11 | ) 12 | 13 | var logger = logging.GetLogger("check") 14 | 15 | // Manager represents check manager 16 | type Manager struct { 17 | collector *collector 18 | sender *sender 19 | } 20 | 21 | // NewManager creates a new check manager 22 | func NewManager(generators []Generator, client api.Client) *Manager { 23 | return &Manager{ 24 | collector: newCollector(generators), 25 | sender: newSender(client), 26 | } 27 | } 28 | 29 | // Configs gets check manager configs 30 | func (m *Manager) Configs() []mackerel.CheckConfig { 31 | return m.collector.configs() 32 | } 33 | 34 | // Run collect and check monitoring reports 35 | func (m *Manager) Run(ctx context.Context, interval time.Duration) (err error) { 36 | t := time.NewTicker(interval) 37 | defer t.Stop() 38 | errCh := make(chan error) 39 | loop: 40 | for { 41 | select { 42 | case <-ctx.Done(): 43 | break loop 44 | case <-t.C: 45 | go func() { 46 | ctx, cancel := context.WithTimeout(context.Background(), time.Minute) 47 | defer cancel() 48 | if err := m.collectAndPostCheckReports(ctx); err != nil { 49 | errCh <- err 50 | } 51 | }() 52 | case err = <-errCh: 53 | break loop 54 | } 55 | } 56 | return 57 | } 58 | 59 | // SetHostID sets host id 60 | func (m *Manager) SetHostID(hostID string) { 61 | m.sender.setHostID(hostID) 62 | } 63 | 64 | func (m *Manager) collectAndPostCheckReports(ctx context.Context) error { 65 | rs := m.collector.collect(ctx) 66 | reports := make([]*mackerel.CheckReport, len(rs)) 67 | for i, r := range rs { 68 | reports[i] = &mackerel.CheckReport{ 69 | Name: r.name, 70 | Status: r.status, 71 | Message: r.message, 72 | OccurredAt: r.occurredAt.Unix(), 73 | } 74 | } 75 | return m.sender.post(reports) 76 | } 77 | -------------------------------------------------------------------------------- /cmdutil/command.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "unicode" 7 | ) 8 | 9 | // Command represents a plugin/probe command to allow string and []string. 10 | type Command struct { 11 | command any 12 | } 13 | 14 | // UnmarshalYAML defines unmarshaler from YAML. 15 | func (c *Command) UnmarshalYAML(unmarshal func(v any) error) (err error) { 16 | var s string 17 | if err := unmarshal(&s); err == nil { 18 | c.command = s 19 | return nil 20 | } 21 | var ss []string 22 | if err := unmarshal(&ss); err != nil { 23 | return err 24 | } 25 | c.command = ss 26 | return nil 27 | } 28 | 29 | // String defines the string representation of Command. 30 | func (c Command) String() string { 31 | switch cmd := c.command.(type) { 32 | case string: 33 | return cmd 34 | case []string: 35 | args := make([]string, len(cmd)) 36 | for i, arg := range cmd { 37 | if strings.IndexFunc(arg, func(c rune) bool { return unicode.IsSpace(c) }) >= 0 { 38 | args[i] = fmt.Sprintf("%q", arg) 39 | continue 40 | } 41 | args[i] = arg 42 | } 43 | return strings.Join(args, " ") 44 | default: 45 | panic("unexpected command type") 46 | } 47 | } 48 | 49 | // ToArgs returns the command arguments. 50 | func (c Command) ToArgs() []string { 51 | switch cmd := c.command.(type) { 52 | case string: 53 | return []string{"/bin/sh", "-c", cmd} 54 | case []string: 55 | return cmd 56 | default: 57 | panic("unexpected command type") 58 | } 59 | } 60 | 61 | // IsEmpty returns the command is empty. 62 | func (c Command) IsEmpty() bool { 63 | switch cmd := c.command.(type) { 64 | case string: 65 | return cmd == "" 66 | case []string: 67 | return len(cmd) == 0 68 | default: 69 | return true 70 | } 71 | } 72 | 73 | // CommandString returns a Command of string. 74 | func CommandString(s string) Command { 75 | return Command{command: s} 76 | } 77 | 78 | // CommandArgs returns a Command of []string. 79 | func CommandArgs(ss []string) Command { 80 | return Command{command: ss} 81 | } 82 | -------------------------------------------------------------------------------- /probe/probe.go: -------------------------------------------------------------------------------- 1 | package probe 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/mackerelio/golib/logging" 8 | 9 | "github.com/mackerelio/mackerel-container-agent/config" 10 | ) 11 | 12 | var logger = logging.GetLogger("probe") 13 | 14 | var ( 15 | defaultPeriod = 10 * time.Second 16 | ) 17 | 18 | // Probe ... 19 | type Probe interface { 20 | Check(context.Context) error 21 | InitialDelay() time.Duration 22 | Period() time.Duration 23 | } 24 | 25 | // NewProbe creates a new Probe 26 | func NewProbe(p *config.Probe) Probe { 27 | initialDelay := time.Duration(p.InitialDelaySeconds) * time.Second 28 | period := time.Duration(p.PeriodSeconds) * time.Second 29 | timeout := time.Duration(p.TimeoutSeconds) * time.Second 30 | if p.Exec != nil { 31 | return &probeExec{ 32 | ProbeExec: p.Exec, 33 | initialDelay: initialDelay, 34 | period: period, 35 | timeout: timeout, 36 | } 37 | } 38 | if p.HTTP != nil { 39 | return &probeHTTP{ 40 | ProbeHTTP: p.HTTP, 41 | initialDelay: initialDelay, 42 | period: period, 43 | timeout: timeout, 44 | } 45 | } 46 | if p.TCP != nil { 47 | return &probeTCP{ 48 | ProbeTCP: p.TCP, 49 | initialDelay: initialDelay, 50 | period: period, 51 | timeout: timeout, 52 | } 53 | } 54 | return nil 55 | } 56 | 57 | // Wait until the probe is ready. 58 | func Wait(ctx context.Context, p Probe) error { 59 | if delay := p.InitialDelay(); delay > 0 { 60 | select { 61 | case <-ctx.Done(): 62 | return ctx.Err() 63 | case <-time.After(delay): 64 | } 65 | } 66 | period := p.Period() 67 | if period == 0 { 68 | period = defaultPeriod 69 | } 70 | loop: 71 | for { 72 | select { 73 | case <-ctx.Done(): 74 | return ctx.Err() 75 | default: 76 | err := p.Check(ctx) 77 | if err != nil { 78 | logger.Infof("%s", err) 79 | } else { 80 | break loop 81 | } 82 | } 83 | select { 84 | case <-ctx.Done(): 85 | return ctx.Err() 86 | case <-time.After(period): 87 | } 88 | } 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /metric/generator_test.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "errors" 5 | 6 | mackerel "github.com/mackerelio/mackerel-client-go" 7 | ) 8 | 9 | func createMockGenerators() []Generator { 10 | g1 := NewMockGenerator(Values{ 11 | "custom.foo.bar": 10.0, 12 | "custom.foo.baz": 20.0, 13 | "custom.foo.qux": 30.0, 14 | }, nil, []*mackerel.GraphDefsParam{ 15 | &mackerel.GraphDefsParam{ 16 | Name: "custom.foo", 17 | DisplayName: "Foo graph", 18 | Unit: "float", 19 | Metrics: []*mackerel.GraphDefsMetric{ 20 | &mackerel.GraphDefsMetric{ 21 | Name: "custom.foo.bar", 22 | DisplayName: "Bar", 23 | IsStacked: false, 24 | }, 25 | &mackerel.GraphDefsMetric{ 26 | Name: "custom.foo.baz", 27 | DisplayName: "Baz", 28 | IsStacked: false, 29 | }, 30 | &mackerel.GraphDefsMetric{ 31 | Name: "custom.foo.qux", 32 | DisplayName: "Qux", 33 | IsStacked: false, 34 | }, 35 | }, 36 | }, 37 | }, nil) 38 | g2 := NewMockGenerator(Values{ 39 | "custom.qux.a.bar": 12.39, 40 | "custom.qux.a.baz": 13.41, 41 | "custom.qux.b.bar": 14.43, 42 | "custom.qux.b.baz": 15.45, 43 | }, nil, []*mackerel.GraphDefsParam{ 44 | &mackerel.GraphDefsParam{ 45 | Name: "custom.qux.#", 46 | DisplayName: "Qux graph", 47 | Unit: "percentage", 48 | Metrics: []*mackerel.GraphDefsMetric{ 49 | &mackerel.GraphDefsMetric{ 50 | Name: "custom.qux.#.bar", 51 | DisplayName: "Bar", 52 | IsStacked: false, 53 | }, 54 | &mackerel.GraphDefsMetric{ 55 | Name: "custom.qux.#.baz", 56 | DisplayName: "Baz", 57 | IsStacked: false, 58 | }, 59 | }, 60 | }, 61 | }, nil) 62 | g3 := NewMockGenerator(Values{ 63 | "loadavg5": 2.39, 64 | "cpu.user.percentage": 29.2, 65 | }, nil, nil, nil) 66 | g4 := NewMockGenerator( 67 | Values{}, 68 | errors.New("failed to fetch metrics"), 69 | nil, 70 | errors.New("failed to create graph definition"), 71 | ) 72 | return []Generator{g1, g2, g3, g4} 73 | } 74 | -------------------------------------------------------------------------------- /platform/kubernetes/kubelet/mock_client.go: -------------------------------------------------------------------------------- 1 | package kubelet 2 | 3 | import ( 4 | "context" 5 | 6 | kubernetesTypes "k8s.io/api/core/v1" 7 | kubeletTypes "k8s.io/kubelet/pkg/apis/stats/v1alpha1" 8 | ) 9 | 10 | // MockClient represents a mock client of Kubelet APIs 11 | type MockClient struct { 12 | getPodCallback func(context.Context) (*kubernetesTypes.Pod, error) 13 | getPodStatsCallback func(context.Context) (*kubeletTypes.PodStats, error) 14 | } 15 | 16 | // MockClientOption represents an option of mock client of Kubelet APIs 17 | type MockClientOption func(*MockClient) 18 | 19 | // NewMockClient creates a new mock client of Kubelet APIs 20 | func NewMockClient(opts ...MockClientOption) *MockClient { 21 | c := &MockClient{} 22 | for _, o := range opts { 23 | c.ApplyOption(o) 24 | } 25 | return c 26 | } 27 | 28 | // ApplyOption applies a mock client option 29 | func (c *MockClient) ApplyOption(opt MockClientOption) { 30 | opt(c) 31 | } 32 | 33 | type errCallbackNotFound string 34 | 35 | func (err errCallbackNotFound) Error() string { 36 | return string(err) + " callback not found" 37 | } 38 | 39 | // GetPod ... 40 | func (c *MockClient) GetPod(ctx context.Context) (*kubernetesTypes.Pod, error) { 41 | if c.getPodCallback != nil { 42 | return c.getPodCallback(ctx) 43 | } 44 | return nil, errCallbackNotFound("GetPod") 45 | } 46 | 47 | // MockGetPod returns an option to set the callback of GetPod 48 | func MockGetPod(callback func(context.Context) (*kubernetesTypes.Pod, error)) MockClientOption { 49 | return func(c *MockClient) { 50 | c.getPodCallback = callback 51 | } 52 | } 53 | 54 | // GetPodStats ... 55 | func (c *MockClient) GetPodStats(ctx context.Context) (*kubeletTypes.PodStats, error) { 56 | if c.getPodStatsCallback != nil { 57 | return c.getPodStatsCallback(ctx) 58 | } 59 | return nil, errCallbackNotFound("GetPodStats") 60 | } 61 | 62 | // MockGetPodStats returns an option to set the callback of GetPodStats 63 | func MockGetPodStats(callback func(context.Context) (*kubeletTypes.PodStats, error)) MockClientOption { 64 | return func(c *MockClient) { 65 | c.getPodStatsCallback = callback 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /metric/manager.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/mackerelio/golib/logging" 8 | mackerel "github.com/mackerelio/mackerel-client-go" 9 | 10 | "github.com/mackerelio/mackerel-container-agent/api" 11 | ) 12 | 13 | var logger = logging.GetLogger("metric") 14 | 15 | // Manager in metric manager 16 | type Manager struct { 17 | collector *collector 18 | sender *sender 19 | } 20 | 21 | // NewManager creates metric manager instanace 22 | func NewManager(generators []Generator, client api.Client) *Manager { 23 | return &Manager{ 24 | collector: newCollector(generators), 25 | sender: newSender(client), 26 | } 27 | } 28 | 29 | // Run collect and send metrics 30 | func (m *Manager) Run(ctx context.Context, interval time.Duration) (err error) { 31 | t := time.NewTicker(interval) 32 | defer t.Stop() 33 | errCh := make(chan error) 34 | loop: 35 | for { 36 | select { 37 | case <-ctx.Done(): 38 | break loop 39 | case <-t.C: 40 | go func() { 41 | ctx, cancel := context.WithTimeout(context.Background(), time.Minute) 42 | defer cancel() 43 | if err := m.collectAndPostValues(ctx); err != nil { 44 | errCh <- err 45 | } 46 | }() 47 | case err = <-errCh: 48 | break loop 49 | } 50 | } 51 | return 52 | } 53 | 54 | // SetHostID sets host id 55 | func (m *Manager) SetHostID(hostID string) { 56 | m.sender.setHostID(hostID) 57 | } 58 | 59 | func (m *Manager) collectAndPostValues(ctx context.Context) error { 60 | now := time.Now() 61 | values, err := m.collector.collect(ctx) 62 | if err != nil { 63 | return err 64 | } 65 | if len(values) == 0 { 66 | return nil 67 | } 68 | var metricValues []*mackerel.MetricValue 69 | for name, value := range values { 70 | metricValues = append(metricValues, &mackerel.MetricValue{ 71 | Name: name, 72 | Time: now.Unix(), 73 | Value: value, 74 | }) 75 | } 76 | return m.sender.post(metricValues) 77 | } 78 | 79 | // CollectAndPostGraphDefs sends graph definitions 80 | func (m *Manager) CollectAndPostGraphDefs(ctx context.Context) error { 81 | graphDefs, err := m.collector.collectGraphDefs(ctx) 82 | if err != nil { 83 | return err 84 | } 85 | return m.sender.postGraphDefs(graphDefs) 86 | } 87 | -------------------------------------------------------------------------------- /cmdutil/cmdutil_test.go: -------------------------------------------------------------------------------- 1 | package cmdutil 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "syscall" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestRunCommand(t *testing.T) { 12 | testCases := []struct { 13 | name string 14 | command Command 15 | user string 16 | env []string 17 | timeout time.Duration 18 | stdout, stderr string 19 | exitCode int 20 | err error 21 | }{ 22 | { 23 | name: "echo 1", 24 | command: CommandString("echo 1"), 25 | stdout: "1\n", 26 | }, 27 | { 28 | name: "stdout stderr", 29 | command: CommandString("echo foobar && echo quxquux >&2"), 30 | stdout: "foobar\n", 31 | stderr: "quxquux\n", 32 | }, 33 | { 34 | name: "exit status", 35 | command: CommandString("exit 42"), 36 | exitCode: 42, 37 | err: nil, 38 | }, 39 | { 40 | name: "environment variables", 41 | command: CommandString("echo $FOO; echo $BAR >&2"), 42 | env: []string{"FOO=foo bar", "BAR=qux quux"}, 43 | stdout: "foo bar\n", 44 | stderr: "qux quux\n", 45 | }, 46 | { 47 | name: "timeout", 48 | command: CommandString("sleep 3"), 49 | timeout: 100 * time.Millisecond, 50 | exitCode: 128 + int(syscall.SIGTERM), 51 | err: errTimedOut, 52 | }, 53 | { 54 | name: "command not found", 55 | command: CommandString("notfound"), 56 | exitCode: 127, 57 | stderr: " not found\n", 58 | }, 59 | { 60 | name: "command args", 61 | command: CommandArgs([]string{"echo", "foo", "bar"}), 62 | stdout: "foo bar\n", 63 | }, 64 | } 65 | 66 | for _, tc := range testCases { 67 | t.Run(tc.name, func(t *testing.T) { 68 | ctx := context.Background() 69 | stdout, stderr, exitCode, err := RunCommand(ctx, tc.command, tc.user, tc.env, tc.timeout) 70 | if stdout != tc.stdout { 71 | t.Errorf("invalid stdout (out: %q, expect: %q)", stdout, tc.stdout) 72 | } 73 | if tc.stderr == "" && stderr != "" || !strings.Contains(stderr, tc.stderr) { 74 | t.Errorf("invalid stderr (out: %q, expect: %q)", stderr, tc.stderr) 75 | } 76 | if exitCode != tc.exitCode { 77 | t.Errorf("exitCode should be %d, but: %d", tc.exitCode, exitCode) 78 | } 79 | if err != tc.err { 80 | t.Errorf("err should be %v but: %v", tc.err, err) 81 | } 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /platform/kubernetes/kubernetes_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/pem" 7 | "net" 8 | "net/http" 9 | "net/http/httptest" 10 | "testing" 11 | 12 | kubernetesTypes "k8s.io/api/core/v1" 13 | 14 | "github.com/mackerelio/mackerel-container-agent/platform/kubernetes/kubelet" 15 | ) 16 | 17 | func TestStatusRunning(t *testing.T) { 18 | mockClient := kubelet.NewMockClient() 19 | pform := kubernetesPlatform{mockClient} 20 | 21 | tests := []struct { 22 | status string 23 | expect bool 24 | }{ 25 | {"running", true}, 26 | {"Running", true}, 27 | {"RUNNING", true}, 28 | {"PENDING", false}, 29 | {"", false}, 30 | } 31 | 32 | for _, tc := range tests { 33 | ctx := context.Background() 34 | mockClient.ApplyOption( 35 | kubelet.MockGetPod( 36 | func(context.Context) (*kubernetesTypes.Pod, error) { 37 | return &kubernetesTypes.Pod{ 38 | Status: kubernetesTypes.PodStatus{Phase: kubernetesTypes.PodPhase(tc.status)}, 39 | }, nil 40 | }, 41 | ), 42 | ) 43 | 44 | got := pform.StatusRunning(ctx) 45 | if got != tc.expect { 46 | t.Errorf("StatusRunning() expected %t, got %t", tc.expect, got) 47 | } 48 | } 49 | } 50 | 51 | func TestCreateHTTPClient(t *testing.T) { 52 | ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) 53 | host, port, _ := net.SplitHostPort(ts.Listener.Addr().String()) 54 | 55 | caCert := &bytes.Buffer{} 56 | err := pem.Encode(caCert, &pem.Block{Type: "CERTIFICATE", Bytes: ts.Certificate().Raw}) 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | tests := []struct { 62 | caCert []byte 63 | insecureTLS bool 64 | expect bool 65 | }{ 66 | {caCert.Bytes(), false, true}, 67 | {caCert.Bytes(), true, true}, 68 | {[]byte{}, false, false}, 69 | {[]byte{}, true, true}, 70 | } 71 | 72 | url := "https://" + net.JoinHostPort(host, port) 73 | 74 | for _, tc := range tests { 75 | client := createHTTPClient(tc.caCert, tc.insecureTLS) 76 | resp, err := client.Get(url) 77 | if (err == nil) != tc.expect { 78 | t.Errorf("Get() does not expected benavior: %v", err) 79 | } 80 | if resp != nil { 81 | resp.Body.Close() // nolint 82 | } 83 | if client.Transport.(*http.Transport).Proxy != nil { 84 | t.Error("proxy should not be used") 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /check/plugin.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "time" 7 | 8 | mackerel "github.com/mackerelio/mackerel-client-go" 9 | 10 | "github.com/mackerelio/mackerel-container-agent/cmdutil" 11 | "github.com/mackerelio/mackerel-container-agent/config" 12 | ) 13 | 14 | type pluginGenerator struct { 15 | config.CheckPlugin 16 | lastResult *Result 17 | } 18 | 19 | // NewPluginGenerator creates a new check generator 20 | func NewPluginGenerator(p *config.CheckPlugin) Generator { 21 | return &pluginGenerator{*p, nil} 22 | } 23 | 24 | // Config gets check generator config 25 | func (g *pluginGenerator) Config() mackerel.CheckConfig { 26 | return mackerel.CheckConfig{Name: g.Name, Memo: g.Memo} 27 | } 28 | 29 | // Generate generates check report 30 | func (g *pluginGenerator) Generate(ctx context.Context) (*Result, error) { 31 | now := time.Now() 32 | var masked_env []string 33 | for _, v := range g.Env { 34 | key := strings.Split(v, "=")[0] 35 | value := strings.Split(v, "=")[1] 36 | masked_env = append(masked_env, key+"="+config.MaskEnvValue(value)) 37 | } 38 | logger.Debugf("plugin %s command: %s env: %+v", g.Name, g.Command, masked_env) 39 | stdout, stderr, exitCode, err := cmdutil.RunCommand(ctx, g.Command, g.User, g.Env, g.Timeout) 40 | 41 | if stderr != "" { 42 | logger.Infof("plugin %s (%s): %q", g.Name, g.Command, stderr) 43 | } 44 | 45 | var message string 46 | var status mackerel.CheckStatus 47 | if err != nil { 48 | logger.Warningf("plugin %s (%s): %s", g.Name, g.Command, err) 49 | message = err.Error() 50 | status = mackerel.CheckStatusUnknown 51 | } else { 52 | message = stdout 53 | status = exitCodeToStatus(exitCode) 54 | } 55 | 56 | newResult := NewResult(g.Name, message, status, now) 57 | 58 | lastResult := g.lastResult 59 | g.lastResult = newResult 60 | if lastResult == nil { 61 | return newResult, nil 62 | } 63 | if lastResult.status == mackerel.CheckStatusOK && newResult.status == mackerel.CheckStatusOK { 64 | // do not report ok -> ok 65 | return nil, nil 66 | } 67 | return newResult, nil 68 | } 69 | 70 | func exitCodeToStatus(exitCode int) mackerel.CheckStatus { 71 | switch exitCode { 72 | case 0: 73 | return mackerel.CheckStatusOK 74 | case 1: 75 | return mackerel.CheckStatusWarning 76 | case 2: 77 | return mackerel.CheckStatusCritical 78 | default: 79 | return mackerel.CheckStatusUnknown 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /docs/architecture.dot: -------------------------------------------------------------------------------- 1 | digraph { 2 | rankdir=LR; 3 | 4 | "agent.Run" -> "metric.Manager"; 5 | "agent.Run" -> "spec.Manager"; 6 | "agent.Run" -> "check.Manager"; 7 | "agent.Run" -> "config.Loader"; 8 | "agent.Run" -> "probe.Probe"; 9 | "agent.Run" -> "platform.Platform"; 10 | "agent.Run" -> "agent.hostResolver"; 11 | "agent.hostResolver" -> "api.Client"; 12 | 13 | subgraph check { 14 | label="check"; 15 | "check.Manager" -> "check.collector" [dir=back label="[]*check.Result"]; 16 | "check.collector" -> "[]check.Generator" [dir=back label="*check.Result"]; 17 | "check.Manager" -> "check.sender" [label="[]*mackerel.CheckReport"]; 18 | } 19 | 20 | subgraph metric { 21 | label="metric"; 22 | "metric.Manager" -> "metric.collector" [dir=back label="metric.Values"]; 23 | "metric.collector" -> "[]metric.Generator" [dir=back label="metric.Values"]; 24 | "metric.Manager" -> "metric.sender" [label="[]*mackerel.MetricValue"]; 25 | } 26 | 27 | subgraph spec { 28 | label="spec"; 29 | "spec.Manager" -> "spec.collector" [dir=back label="mackerel.HostMeta"]; 30 | "spec.collector" -> "[]spec.Generator" [dir=back label="any"]; 31 | "spec.Manager" -> "spec.sender" [label="*mackerel.UpdateHostParam"]; 32 | } 33 | 34 | subgraph config { 35 | label="config"; 36 | "config.Loader" -> "config.Config" [label="Load"]; 37 | "config.Config" -> configMetricGenerator [label="MetricPlugins"]; 38 | configMetricGenerator [label="[]metric.Generator"]; 39 | "config.Config" -> configCheckGenerator [label="CheckPlugins"]; 40 | configCheckGenerator[label="[]check.Generator"]; 41 | } 42 | 43 | subgraph probe { 44 | label="probe"; 45 | "config.Config" -> "probe.Probe" [label="ReadinessProbe"]; 46 | } 47 | 48 | subgraph platform { 49 | label="platform"; 50 | "platform.Platform" -> platformMetricGenerator [label="GetMetricGenerators"]; 51 | platformMetricGenerator [label="[]metric.Generator"]; 52 | "platform.Platform" -> platformSpecGenerator [label="GetSpecGenerators"]; 53 | platformSpecGenerator [label="[]spec.Generator"]; 54 | } 55 | 56 | { rank=same; "[]metric.Generator"; configMetricGenerator; platformMetricGenerator; } 57 | { rank=same; "[]check.Generator"; configCheckGenerator; } 58 | { rank=same; "[]spec.Generator"; platformSpecGenerator; } 59 | 60 | "check.sender" -> "api.Client"; 61 | "metric.sender" -> "api.Client"; 62 | "spec.sender" -> "api.Client"; 63 | "api.Client" -> "Mackerel API"; 64 | } 65 | -------------------------------------------------------------------------------- /platform/ecs/spec_test.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "os" 7 | "testing" 8 | 9 | ecsTypes "github.com/mackerelio/mackerel-container-agent/internal/amazon-ecs-agent/agent/handlers/v2" 10 | "github.com/mackerelio/mackerel-container-agent/platform/ecs/internal" 11 | agentSpec "github.com/mackerelio/mackerel-container-agent/spec" 12 | ) 13 | 14 | func TestGenerateSpec(t *testing.T) { 15 | tests := []struct { 16 | path string 17 | provider provider 18 | }{ 19 | {"taskmetadata/testdata/metadata_ec2_bridge.json", ecsProvider}, 20 | {"taskmetadata/testdata/metadata_ec2_host.json", ecsProvider}, 21 | {"taskmetadata/testdata/metadata_ec2_awsvpc.json", ecsProvider}, 22 | {"taskmetadata/testdata/metadata_fargate.json", fargateProvider}, 23 | {"taskmetadata/testdata/metadata_ecs_anywhere.json", ecsAnywhereProvider}, 24 | } 25 | 26 | var path string 27 | mock := internal.NewMockTaskMetadataGetter( 28 | internal.MockGetTaskMetadata( 29 | func(ctx context.Context) (*ecsTypes.TaskResponse, error) { 30 | f, err := os.Open(path) 31 | if err != nil { 32 | return nil, err 33 | } 34 | defer f.Close() // nolint 35 | var res ecsTypes.TaskResponse 36 | if err := json.NewDecoder(f).Decode(&res); err != nil { 37 | return nil, err 38 | } 39 | return &res, nil 40 | }, 41 | ), 42 | ) 43 | 44 | ctx := context.Background() 45 | 46 | for _, tc := range tests { 47 | path = tc.path 48 | 49 | g := newSpecGenerator(mock, tc.provider) 50 | 51 | spec, err := g.Generate(ctx) 52 | if err != nil { 53 | t.Errorf("Generate() should not raise error: %v", err) 54 | } 55 | 56 | got, ok := spec.(*agentSpec.CloudHostname) 57 | if !ok { 58 | t.Errorf("Generate() should return *spec.CloudHostname, got %T", got) 59 | } 60 | 61 | if got.Hostname != "task-id" { 62 | t.Errorf("Hostname expected %v, got %v", "task-id", got) 63 | } 64 | if got.Cloud.MetaData == nil { 65 | t.Error("MetaData should not be nil") 66 | } 67 | t.Logf("%+v\n\n", got.Cloud.MetaData) 68 | } 69 | } 70 | 71 | func TestGetTaskID(t *testing.T) { 72 | tests := []struct { 73 | taskARN string 74 | expected string 75 | }{ 76 | {"arn:aws:ecs:us-east-1:012345678910:task/task-id", "task-id"}, 77 | {"arn:aws:ecs:us-east-1:012345678910:task/cluster-name/task-id", "task-id"}, 78 | } 79 | 80 | for _, tc := range tests { 81 | got, err := getTaskID(tc.taskARN) 82 | if err != nil { 83 | t.Errorf("getTaskID() should not raise error: %v", err) 84 | } 85 | if got != tc.expected { 86 | t.Errorf("getTaskID() expected %v, got %v", tc.expected, got) 87 | } 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /config/probe.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | 7 | "github.com/mackerelio/mackerel-container-agent/cmdutil" 8 | ) 9 | 10 | // Probe configuration. 11 | type Probe struct { 12 | Exec *ProbeExec `yaml:"exec"` 13 | HTTP *ProbeHTTP `yaml:"http"` 14 | TCP *ProbeTCP `yaml:"tcp"` 15 | InitialDelaySeconds int `yaml:"initialDelaySeconds"` 16 | PeriodSeconds int `yaml:"periodSeconds"` 17 | TimeoutSeconds int `yaml:"timeoutSeconds"` 18 | } 19 | 20 | func (p *Probe) validate() error { 21 | if p.Exec != nil && p.HTTP != nil || p.HTTP != nil && p.TCP != nil || p.TCP != nil && p.Exec != nil { 22 | return errors.New("either one of exec, http or tcp can be configured for probe") 23 | } 24 | if p.Exec == nil && p.HTTP == nil && p.TCP == nil { 25 | return errors.New("configure exec, http or tcp for probe") 26 | } 27 | if p.Exec != nil && p.Exec.Command.IsEmpty() { 28 | return errors.New("specify command of exec probe") 29 | } 30 | if p.HTTP != nil && p.HTTP.Path == "" { 31 | return errors.New("specify path of http probe") 32 | } 33 | if p.TCP != nil && p.TCP.Port == "" { 34 | return errors.New("specify port of tcp probe") 35 | } 36 | if p.InitialDelaySeconds < 0 { 37 | return errors.New("initialDelaySeconds should be positive") 38 | } 39 | if p.PeriodSeconds < 0 { 40 | return errors.New("periodSeconds should be positive") 41 | } 42 | if p.TimeoutSeconds < 0 { 43 | return errors.New("timeoutSeconds should be positive") 44 | } 45 | return nil 46 | } 47 | 48 | // ProbeExec is a probe with command. 49 | type ProbeExec struct { 50 | Command cmdutil.Command `yaml:"command"` 51 | User string `yaml:"user"` 52 | Env Env `yaml:"env"` 53 | } 54 | 55 | // ProbeHTTP is a probe with http. 56 | type ProbeHTTP struct { 57 | Scheme string `yaml:"scheme"` 58 | Method string `yaml:"method"` 59 | Host string `yaml:"host"` 60 | Port string `yaml:"port"` 61 | Path string `yaml:"path"` 62 | Headers []Header `yaml:"headers"` 63 | UserAgent string 64 | Proxy URLWrapper `yaml:"proxy"` 65 | } 66 | 67 | // Header is a request header for http probe. 68 | type Header struct { 69 | Name string `yaml:"name"` 70 | Value string `yaml:"value"` 71 | } 72 | 73 | // URLWrapper wraps url.URL 74 | type URLWrapper struct { 75 | *url.URL 76 | } 77 | 78 | // UnmarshalText decodes host status string 79 | func (u *URLWrapper) UnmarshalText(text []byte) error { 80 | var err error 81 | u.URL, err = url.Parse(string(text)) 82 | return err 83 | } 84 | 85 | // ProbeTCP is a probe with tcp. 86 | type ProbeTCP struct { 87 | Host string `yaml:"host"` 88 | Port string `yaml:"port"` 89 | } 90 | -------------------------------------------------------------------------------- /cmd/mackerel-container-agent/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "runtime/debug" 6 | "strings" 7 | 8 | "github.com/mackerelio/golib/logging" 9 | 10 | "github.com/mackerelio/mackerel-container-agent/agent" 11 | "github.com/mackerelio/mackerel-container-agent/config" 12 | ) 13 | 14 | const cmdName = "mackerel-container-agent" 15 | 16 | var logger = logging.GetLogger("main") 17 | 18 | func main() { 19 | logLevel := os.Getenv("MACKEREL_LOG_LEVEL") 20 | switch logLevel { 21 | case "TRACE": 22 | logging.SetLogLevel(logging.TRACE) 23 | case "DEBUG": 24 | logging.SetLogLevel(logging.DEBUG) 25 | case "INFO": 26 | logging.SetLogLevel(logging.INFO) 27 | case "WARNING": 28 | logging.SetLogLevel(logging.WARNING) 29 | case "ERROR": 30 | logging.SetLogLevel(logging.ERROR) 31 | case "CRITICAL": 32 | logging.SetLogLevel(logging.CRITICAL) 33 | default: 34 | logging.SetLogLevel(logging.INFO) 35 | } 36 | 37 | logger.Debugf("MACKEREL_APIBASE=%s", config.MaskEnvValue(os.Getenv("MACKEREL_APIBASE"))) 38 | logger.Debugf("MACKEREL_APIKEY=%s", config.MaskEnvValue(os.Getenv("MACKEREL_APIKEY"))) 39 | 40 | env := []string{ 41 | "MACKEREL_AGENT_CONFIG_POLLING_DURATION_MINUTES", 42 | "MACKEREL_AGENT_CONFIG", 43 | "MACKEREL_AGENT_PLUGIN_META", 44 | "MACKEREL_CONTAINER_PLATFORM", 45 | "MACKEREL_HOST_STATUS_ON_START", 46 | "MACKEREL_IGNORE_CONTAINER", 47 | "MACKEREL_KUBERNETES_KUBELET_HOST", 48 | "MACKEREL_KUBERNETES_KUBELET_INSECURE_TLS", 49 | "MACKEREL_KUBERNETES_KUBELET_READ_ONLY_PORT", 50 | "MACKEREL_KUBERNETES_NAMESPACE", 51 | "MACKEREL_KUBERNETES_POD_NAME", 52 | "MACKEREL_LOG_LEVEL", 53 | "MACKEREL_ROLES", 54 | "MACKEREL_DISPLAY_NAME", 55 | "MACKEREL_MEMO", 56 | } 57 | for _, v := range env { 58 | logger.Debugf("%s=%s", v, os.Getenv(v)) 59 | } 60 | 61 | os.Exit(run(os.Args[1:])) 62 | } 63 | 64 | func run(args []string) int { 65 | version, revision := fromVCS() 66 | logger.Infof("starting %s (version:%s, revision:%s)", cmdName, version, revision) 67 | if err := agent.NewAgent(version, revision).Run(args); err != nil { 68 | logger.Errorf("%s", err) 69 | return 1 70 | } 71 | return 0 72 | } 73 | 74 | func fromVCS() (version, rev string) { 75 | version = "unknown" 76 | rev = "unknown" 77 | info, ok := debug.ReadBuildInfo() 78 | if !ok { 79 | return 80 | } 81 | // trim a prefix `v` 82 | version, _ = strings.CutPrefix(info.Main.Version, "v") 83 | 84 | // strings like "v0.1.2-0.20060102150405-xxxxxxxxxxxx" are long, so they are cut out. 85 | if strings.Contains(version, "-") { 86 | index := strings.IndexRune(version, '-') 87 | version = version[0:index] 88 | } 89 | 90 | for _, s := range info.Settings { 91 | if s.Key == "vcs.revision" { 92 | // emulate "git rev-parse --short HEAD" 93 | rev = s.Value[0:min(len(s.Value), 7)] 94 | return 95 | } 96 | } 97 | return 98 | } 99 | -------------------------------------------------------------------------------- /platform/ecs/types.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | import "time" 4 | 5 | type taskSpec struct { 6 | Cluster string `json:"cluster,omitempty"` 7 | Task string `json:"task,omitempty"` 8 | TaskARN string `json:"task_arn,omitempty"` 9 | TaskFamily string `json:"task_family,omitempty"` 10 | TaskVersion string `json:"task_version,omitempty"` 11 | DesiredStatus string `json:"desired_status,omitempty"` 12 | KnownStatus string `json:"known_status,omitempty"` 13 | Containers []containerSpec `json:"containers,omitempty"` 14 | PullStartedAt *time.Time `json:"pull_started_at,omitempty"` 15 | PullStoppedAt *time.Time `json:"pull_stopped_at,omitempty"` 16 | ExecutionStoppedAt *time.Time `json:"execution_stopped_at,omitempty"` 17 | Limits limitSpec `json:"limits,omitempty"` 18 | } 19 | 20 | type containerSpec struct { 21 | DockerID string `json:"docker_id,omitempty"` 22 | DockerName string `json:"docker_name,omitempty"` 23 | Name string `json:"name,omitempty"` 24 | Image string `json:"image,omitempty"` 25 | ImageID string `json:"image_id,omitempty"` 26 | Ports []portSpec `json:"ports,omitempty"` 27 | Labels map[string]string `json:"labels,omitempty"` 28 | DesiredStatus string `json:"desired_status,omitempty"` 29 | KnownStatus string `json:"known_status,omitempty"` 30 | ExitCode *int `json:"exit_code,omitempty"` 31 | Limits limitSpec `json:"limits,omitempty"` 32 | CreatedAt *time.Time `json:"created_at,omitempty"` 33 | StartedAt *time.Time `json:"started_at,omitempty"` 34 | FinishedAt *time.Time `json:"finished_at,omitempty"` 35 | Type string `json:"type,omitempty"` 36 | Networks []networkSpec `json:"networks,omitempty"` 37 | Health *healthStatus `json:"health,omitempty"` 38 | } 39 | 40 | type limitSpec struct { 41 | CPU *float64 `json:"cpu,omitempty"` 42 | Memory *int64 `json:"memory,omitempty"` 43 | } 44 | 45 | type portSpec struct { 46 | ContainerPort uint16 `json:"container_port,omitempty"` 47 | Protocol string `json:"protocol,omitempty"` 48 | HostPort uint16 `json:"host_port,omitempty"` 49 | } 50 | 51 | type networkSpec struct { 52 | NetworkMode string `json:"network_mode,omitempty"` 53 | IPv4Addresses []string `json:"ipv4_addresses,omitempty"` 54 | IPv6Addresses []string `json:"ipv6_addresses,omitempty"` 55 | } 56 | 57 | type healthStatus struct { 58 | Status int32 `json:"status,omitempty"` 59 | Since *time.Time `json:"status_since,omitempty"` 60 | ExitCode int `json:"exit_code,omitempty"` 61 | Output string `json:"output,omitempty"` 62 | } 63 | -------------------------------------------------------------------------------- /probe/http.go: -------------------------------------------------------------------------------- 1 | package probe 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | "time" 11 | 12 | "github.com/mackerelio/mackerel-container-agent/config" 13 | ) 14 | 15 | var ( 16 | defaultTimeoutHTTP = 1 * time.Second 17 | ) 18 | 19 | type probeHTTP struct { 20 | *config.ProbeHTTP 21 | initialDelay time.Duration 22 | period time.Duration 23 | timeout time.Duration 24 | } 25 | 26 | func (p *probeHTTP) Check(ctx context.Context) error { 27 | u, err := url.Parse(p.Path) 28 | if err != nil { 29 | return err 30 | } 31 | if u.Scheme = p.Scheme; u.Scheme == "" { 32 | u.Scheme = "http" 33 | } 34 | if p.Port != "" { 35 | host := p.Host 36 | if host == "" { 37 | host = "localhost" 38 | } 39 | u.Host = net.JoinHostPort(host, p.Port) 40 | } else if p.Host != "" { 41 | u.Host = p.Host 42 | } else { 43 | u.Host = "localhost" 44 | } 45 | 46 | client, err := p.createHTTPClient() 47 | if err != nil { 48 | return err 49 | } 50 | 51 | method := strings.ToUpper(p.Method) 52 | if method == "" { 53 | method = "GET" 54 | } 55 | req, err := http.NewRequest(method, u.String(), nil) 56 | if err != nil { 57 | return err 58 | } 59 | for _, h := range p.Headers { 60 | if strings.ToLower(h.Name) == "host" { 61 | req.Host = h.Value 62 | } else { 63 | req.Header.Add(h.Name, h.Value) 64 | } 65 | } 66 | if req.Header.Get("User-Agent") == "" && p.UserAgent != "" { 67 | req.Header.Set("User-Agent", p.UserAgent) 68 | } 69 | 70 | res, err := client.Do(req.WithContext(ctx)) 71 | if err != nil { 72 | return fmt.Errorf("http probe failed (%s %s): %w", method, u, err) 73 | } 74 | defer res.Body.Close() // nolint 75 | 76 | if res.StatusCode < http.StatusOK || http.StatusBadRequest <= res.StatusCode { 77 | return fmt.Errorf("http probe failed (%s %s): %s", method, u, res.Status) 78 | } 79 | 80 | logger.Infof("http probe success (%s %s): %s", method, u, res.Status) 81 | return nil 82 | } 83 | 84 | func (p *probeHTTP) InitialDelay() time.Duration { 85 | return p.initialDelay 86 | } 87 | 88 | func (p *probeHTTP) Period() time.Duration { 89 | return p.period 90 | } 91 | 92 | func (p *probeHTTP) createHTTPClient() (*http.Client, error) { 93 | dt := http.DefaultTransport.(*http.Transport) 94 | tp := &http.Transport{ 95 | DialContext: dt.DialContext, 96 | MaxIdleConns: dt.MaxIdleConns, 97 | IdleConnTimeout: dt.IdleConnTimeout, 98 | TLSHandshakeTimeout: dt.TLSHandshakeTimeout, 99 | ExpectContinueTimeout: dt.ExpectContinueTimeout, 100 | Proxy: http.ProxyURL(p.Proxy.URL), 101 | } 102 | 103 | timeout := p.timeout 104 | if timeout == 0 { 105 | timeout = defaultTimeoutHTTP 106 | } 107 | 108 | c := &http.Client{ 109 | Timeout: timeout, 110 | Transport: tp, 111 | } 112 | 113 | return c, nil 114 | } 115 | -------------------------------------------------------------------------------- /probe/probe_test.go: -------------------------------------------------------------------------------- 1 | package probe 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func init() { 11 | defaultPeriod = 100 * time.Millisecond 12 | } 13 | 14 | type mockProbe struct { 15 | results []bool 16 | index int 17 | count int 18 | initialDelay time.Duration 19 | period time.Duration 20 | } 21 | 22 | func newMockProbe(results []bool, initialDelay, period time.Duration) *mockProbe { 23 | return &mockProbe{ 24 | results: results, 25 | initialDelay: initialDelay, 26 | period: period, 27 | } 28 | } 29 | 30 | func (p *mockProbe) Check(ctx context.Context) error { 31 | p.count++ 32 | if p.index < len(p.results) { 33 | p.index++ 34 | } 35 | if !p.results[p.index-1] { 36 | return errors.New("error") 37 | } 38 | return nil 39 | } 40 | 41 | func (p *mockProbe) InitialDelay() time.Duration { 42 | return p.initialDelay 43 | } 44 | 45 | func (p *mockProbe) Period() time.Duration { 46 | return p.period 47 | } 48 | 49 | func TestProbe_Wait(t *testing.T) { 50 | testCases := []struct { 51 | name string 52 | results []bool 53 | initialDelay time.Duration 54 | period time.Duration 55 | count int 56 | accuracy int 57 | duration time.Duration 58 | }{ 59 | { 60 | name: "ok", 61 | results: []bool{true}, 62 | count: 1, 63 | duration: time.Second, 64 | }, 65 | { 66 | name: "fail twice", 67 | results: []bool{false, false, true}, 68 | count: 3, 69 | duration: time.Second, 70 | }, 71 | { 72 | name: "stop by duration", 73 | results: []bool{false}, 74 | count: 3, 75 | accuracy: 1, 76 | duration: 250 * time.Millisecond, 77 | }, 78 | { 79 | name: "period", 80 | results: []bool{false}, 81 | period: 50 * time.Millisecond, 82 | count: 4, 83 | accuracy: 1, 84 | duration: 170 * time.Millisecond, 85 | }, 86 | { 87 | name: "initial delay", 88 | results: []bool{false}, 89 | initialDelay: 200 * time.Millisecond, 90 | count: 2, 91 | accuracy: 1, 92 | duration: 350 * time.Millisecond, 93 | }, 94 | } 95 | 96 | for _, tc := range testCases { 97 | t.Run(tc.name, func(t *testing.T) { 98 | p := newMockProbe(tc.results, tc.initialDelay, tc.period) 99 | ctx, cancel := context.WithTimeout(context.Background(), tc.duration) 100 | defer cancel() 101 | 102 | // Below writing a content sometimes expects returning an error. 103 | // The ctx may timeout while p waits to ready. 104 | Wait(ctx, p) // nolint 105 | 106 | // This test is flaky so we should check the count with an accuracy. 107 | if p.count < tc.count-tc.accuracy || p.count > tc.count+tc.accuracy { 108 | t.Errorf("Wait should check %d times with accuracy %d but got %d", tc.count, tc.accuracy, p.count) 109 | } 110 | }) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /internal/amazon-ecs-agent/agent/api/container/status/containerstatus.go: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"). You may 4 | // not use this file except in compliance with the License. A copy of the 5 | // License is located at 6 | // 7 | // http://aws.amazon.com/apache2.0/ 8 | // 9 | // or in the "license" file accompanying this file. This file is distributed 10 | // on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either 11 | // express or implied. See the License for the specific language governing 12 | // permissions and limitations under the License. 13 | 14 | package status 15 | 16 | import ( 17 | "errors" 18 | "strings" 19 | ) 20 | 21 | const ( 22 | // ContainerHealthUnknown is the initial status of container health 23 | ContainerHealthUnknown ContainerHealthStatus = iota 24 | // ContainerHealthy represents the status of container health check when returned healthy 25 | ContainerHealthy 26 | // ContainerUnhealthy represents the status of container health check when returned unhealthy 27 | ContainerUnhealthy 28 | ) 29 | 30 | // ContainerHealthStatus is an enumeration of container health check status 31 | type ContainerHealthStatus int32 32 | 33 | // BackendStatus returns the container health status recognized by backend 34 | func (healthStatus ContainerHealthStatus) BackendStatus() string { 35 | switch healthStatus { 36 | case ContainerHealthy: 37 | return "HEALTHY" 38 | case ContainerUnhealthy: 39 | return "UNHEALTHY" 40 | default: 41 | return "UNKNOWN" 42 | } 43 | } 44 | 45 | // String returns the readable description of the container health status 46 | func (healthStatus ContainerHealthStatus) String() string { 47 | return healthStatus.BackendStatus() 48 | } 49 | 50 | // UnmarshalJSON overrides the logic for parsing the JSON-encoded container health data 51 | func (healthStatus *ContainerHealthStatus) UnmarshalJSON(b []byte) error { 52 | *healthStatus = ContainerHealthUnknown 53 | 54 | if strings.ToLower(string(b)) == "null" { 55 | return nil 56 | } 57 | if b[0] != '"' || b[len(b)-1] != '"' { 58 | return errors.New("container health status unmarshal: status must be a string or null; Got " + string(b)) 59 | } 60 | 61 | strStatus := string(b[1 : len(b)-1]) 62 | switch strStatus { 63 | case "UNKNOWN": 64 | // The health status is already set to ContainerHealthUnknown initially 65 | case "HEALTHY": 66 | *healthStatus = ContainerHealthy 67 | case "UNHEALTHY": 68 | *healthStatus = ContainerUnhealthy 69 | default: 70 | return errors.New("container health status unmarshal: unrecognized status: " + string(b)) 71 | } 72 | return nil 73 | } 74 | 75 | // MarshalJSON overrides the logic for JSON-encoding the ContainerHealthStatus type 76 | func (healthStatus *ContainerHealthStatus) MarshalJSON() ([]byte, error) { 77 | if healthStatus == nil { 78 | return nil, nil 79 | } 80 | return []byte(`"` + healthStatus.String() + `"`), nil 81 | } 82 | -------------------------------------------------------------------------------- /platform/ecs/taskmetadata/testdata/metadata_fargate.json: -------------------------------------------------------------------------------- 1 | { 2 | "Cluster": "test-clusrer", 3 | "TaskARN": "arn:aws:ecs:ap-northeast-1:999999999999:task/task-id", 4 | "Family": "test-fargate", 5 | "Revision": "1", 6 | "DesiredStatus": "RUNNING", 7 | "KnownStatus": "RUNNING", 8 | "Containers": [ 9 | { 10 | "DockerId": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", 11 | "Name": "~internal~ecs~pause", 12 | "DockerName": "ecs-test-cluster-1-internalecspause-eeeeeeeeeeeeeeeeeeee", 13 | "Image": "amazon/amazon-ecs-pause:0.1.0", 14 | "ImageID": "", 15 | "Labels": { 16 | "com.amazonaws.ecs.cluster": "test-cluster", 17 | "com.amazonaws.ecs.container-name": "~internal~ecs~pause", 18 | "com.amazonaws.ecs.task-arn": "arn:aws:ecs:ap-northeast-1:999999999999:task/task-id", 19 | "com.amazonaws.ecs.task-definition-family": "test-fargate", 20 | "com.amazonaws.ecs.task-definition-version": "1" 21 | }, 22 | "DesiredStatus": "RESOURCES_PROVISIONED", 23 | "KnownStatus": "RESOURCES_PROVISIONED", 24 | "Limits": { 25 | "CPU": 0, 26 | "Memory": 0 27 | }, 28 | "CreatedAt": "2019-03-29T02:55:12.245682179Z", 29 | "StartedAt": "2019-03-29T02:55:12.902702165Z", 30 | "Type": "CNI_PAUSE", 31 | "Networks": [ 32 | { 33 | "NetworkMode": "awsvpc", 34 | "IPv4Addresses": [ 35 | "10.0.10.32" 36 | ] 37 | } 38 | ] 39 | }, 40 | { 41 | "DockerId": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 42 | "Name": "mackerel-container-agent", 43 | "DockerName": "ecs-test-cluster-1-mackerel-container-agent-ffffffffffffffffffff", 44 | "Image": "mackerel/mackerel-container-agent:latest", 45 | "ImageID": "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 46 | "Labels": { 47 | "com.amazonaws.ecs.cluster": "test-cluster", 48 | "com.amazonaws.ecs.container-name": "mackerel-container-agent", 49 | "com.amazonaws.ecs.task-arn": "arn:aws:ecs:ap-northeast-1:999999999999:task/task-id", 50 | "com.amazonaws.ecs.task-definition-family": "test-fargate", 51 | "com.amazonaws.ecs.task-definition-version": "1" 52 | }, 53 | "DesiredStatus": "RUNNING", 54 | "KnownStatus": "RUNNING", 55 | "Limits": { 56 | "CPU": 0, 57 | "Memory": 128 58 | }, 59 | "CreatedAt": "2019-03-29T02:54:57.61447652Z", 60 | "StartedAt": "2019-03-29T02:54:58.346799541Z", 61 | "Type": "NORMAL", 62 | "Networks": [ 63 | { 64 | "NetworkMode": "awsvpc", 65 | "IPv4Addresses": [ 66 | "10.0.10.32" 67 | ] 68 | } 69 | ] 70 | } 71 | ], 72 | "Limits": { 73 | "CPU": 0.25, 74 | "Memory": 256 75 | }, 76 | "PullStartedAt": "2019-03-29T02:54:51.763717413Z", 77 | "PullStoppedAt": "2019-03-29T02:54:58.303055973Z", 78 | "AvailabilityZone": "ap-northeast-1a" 79 | } 80 | -------------------------------------------------------------------------------- /platform/ecs/taskmetadata/testdata/metadata_ec2_awsvpc.json: -------------------------------------------------------------------------------- 1 | { 2 | "Cluster": "test-clusrer", 3 | "TaskARN": "arn:aws:ecs:ap-northeast-1:999999999999:task/task-id", 4 | "Family": "test-ec2-awsvpc", 5 | "Revision": "1", 6 | "DesiredStatus": "RUNNING", 7 | "KnownStatus": "RUNNING", 8 | "Containers": [ 9 | { 10 | "DockerId": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", 11 | "Name": "~internal~ecs~pause", 12 | "DockerName": "ecs-test-cluster-1-internalecspause-eeeeeeeeeeeeeeeeeeee", 13 | "Image": "amazon/amazon-ecs-pause:0.1.0", 14 | "ImageID": "", 15 | "Labels": { 16 | "com.amazonaws.ecs.cluster": "test-clusrer", 17 | "com.amazonaws.ecs.container-name": "~internal~ecs~pause", 18 | "com.amazonaws.ecs.task-arn": "arn:aws:ecs:ap-northeast-1:999999999999:task/task-id", 19 | "com.amazonaws.ecs.task-definition-family": "test-ec2-awsvpc", 20 | "com.amazonaws.ecs.task-definition-version": "1" 21 | }, 22 | "DesiredStatus": "RESOURCES_PROVISIONED", 23 | "KnownStatus": "RESOURCES_PROVISIONED", 24 | "Limits": { 25 | "CPU": 0, 26 | "Memory": 0 27 | }, 28 | "CreatedAt": "2019-03-29T02:55:12.245682179Z", 29 | "StartedAt": "2019-03-29T02:55:12.902702165Z", 30 | "Type": "CNI_PAUSE", 31 | "Networks": [ 32 | { 33 | "NetworkMode": "awsvpc", 34 | "IPv4Addresses": [ 35 | "10.0.10.32" 36 | ] 37 | } 38 | ] 39 | }, 40 | { 41 | "DockerId": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 42 | "Name": "mackerel-container-agent", 43 | "DockerName": "ecs-test-cluster-1-mackerel-container-agent-ffffffffffffffffffff", 44 | "Image": "mackerel/mackerel-container-agent:latest", 45 | "ImageID": "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 46 | "Labels": { 47 | "com.amazonaws.ecs.cluster": "test-cluster", 48 | "com.amazonaws.ecs.container-name": "mackerel-container-agent", 49 | "com.amazonaws.ecs.task-arn": "arn:aws:ecs:ap-northeast-1:999999999999:task/task-id", 50 | "com.amazonaws.ecs.task-definition-family": "test-ec2-awsvpc", 51 | "com.amazonaws.ecs.task-definition-version": "1" 52 | }, 53 | "DesiredStatus": "RUNNING", 54 | "KnownStatus": "RUNNING", 55 | "Limits": { 56 | "CPU": 0, 57 | "Memory": 128 58 | }, 59 | "CreatedAt": "2019-03-29T02:54:57.61447652Z", 60 | "StartedAt": "2019-03-29T02:54:58.346799541Z", 61 | "Type": "NORMAL", 62 | "Networks": [ 63 | { 64 | "NetworkMode": "awsvpc", 65 | "IPv4Addresses": [ 66 | "10.0.10.32" 67 | ] 68 | } 69 | ] 70 | } 71 | ], 72 | "Limits": { 73 | "CPU": 0.25, 74 | "Memory": 256 75 | }, 76 | "PullStartedAt": "2019-03-29T02:54:51.763717413Z", 77 | "PullStoppedAt": "2019-03-29T02:54:58.303055973Z", 78 | "AvailabilityZone": "ap-northeast-1a" 79 | } 80 | -------------------------------------------------------------------------------- /docs/DESIGN.md: -------------------------------------------------------------------------------- 1 | # Design document 2 | ## Code architecture 3 | The following image represents the code architecture of this repository. 4 | 5 | ![](architecture.svg "mackerel-container-agent architecture") 6 | 7 | ## Packages 8 | ### main package 9 | The main package is implemented in cmd/mackerel-container-agent. 10 | 11 | - `os.Exit` is allowed only in this package. 12 | 13 | ### agent package 14 | The agent package implements the main logic of the agent. 15 | 16 | - Load the config using `config.Loader`. If the polling duration is configured, 17 | restart the agent on config changes. 18 | - Restart the agent on receiving `SIGHUP` signal. 19 | - Create Mackerel API client. 20 | - Create `platform.Platform`. 21 | - Collect `[]metric.Generator`, `[]check.Generator` and `[]spec.Generator` from 22 | the platform and config. Create `metric.Manager`, `check.Manager` and 23 | `spec.Manager` from the generators. 24 | - Check the platform status running, readiness probe. 25 | - Create a new host or find the host from the id file or the custom identifier. 26 | When the host id is resolved, pass it to the managers by `SetHostID`. 27 | - Start the loops of each managers by calling `Run`. 28 | - Exit the agent on receiving `SIGINT`, `SIGTERM` or `SIGQUIT` signals. 29 | - Retire the host on exit. 30 | 31 | ### metric package 32 | The metric package implements the logic of collecting and posting metric values. 33 | 34 | - `metric.Manager` has `metric.collector` and `metric.sender`. The manager 35 | periodically collects the metric values from the collector and post them with 36 | the sender. 37 | - `metric.collector` has `[]metric.Generator`. The collector collects metric 38 | values from the generators. 39 | - `metric.sender` has `api.Client` and `hostID`. Note that `hostID` is set 40 | lazily so the metric values are stored on memory until the host id is 41 | resolved. 42 | 43 | ### check package 44 | The check package implements the logic of collecting and posting check reports. 45 | 46 | - The package has almost the same architecture of the metric package. 47 | 48 | ### spec package 49 | The spec package implements the logic of collecting and posting host spec. 50 | 51 | - The package has almost the same architecture of the metric package. 52 | 53 | ### api package 54 | The api package defines the interface of Mackerel API client and mock client. 55 | 56 | - All the senders depend on the `api.Client` interface, not mackerel-client-go. 57 | - Mock client is used in the tests and created in the style of functional 58 | options pattern. 59 | 60 | ### platform package 61 | The platform package defines the `platform.Platform` interface, which has 62 | methods to create the metric and spec generators. 63 | 64 | - There are two platforms; `ecsPlatform` and `kubernetesPlatform`. 65 | 66 | ### probe package 67 | The probe package defines the `probe.Probe` interface, which is used by the 68 | readiness probe feature. 69 | 70 | - There are three probes; `probeExec`, `probeHTTP` and `probeTCP`. 71 | 72 | ### config package 73 | The config package defines `config.Config` and `config.Loader`. 74 | 75 | ### cmdutil package 76 | The cmdutil package defines `cmdutil.RunCommand`, which is used by metric, check 77 | plugins and exec probe. 78 | -------------------------------------------------------------------------------- /spec/manager.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/mackerelio/golib/logging" 10 | mackerel "github.com/mackerelio/mackerel-client-go" 11 | 12 | "github.com/mackerelio/mackerel-container-agent/api" 13 | ) 14 | 15 | var logger = logging.GetLogger("spec") 16 | 17 | // Manager in spec manager 18 | type Manager struct { 19 | collector *collector 20 | sender *sender 21 | checks []mackerel.CheckConfig 22 | version, revision string 23 | customIdentifier string 24 | } 25 | 26 | // NewManager creates spec manager instanace 27 | func NewManager(generators []Generator, client api.Client) *Manager { 28 | return &Manager{ 29 | collector: newCollector(generators), 30 | sender: newSender(client), 31 | } 32 | } 33 | 34 | // WithVersion sets agent version and revision 35 | func (m *Manager) WithVersion(version, revision string) *Manager { 36 | m.version, m.revision = version, revision 37 | return m 38 | } 39 | 40 | // WithCustomIdentifier sets platform customIdentifier 41 | func (m *Manager) WithCustomIdentifier(customIdentifier string) *Manager { 42 | m.customIdentifier = customIdentifier 43 | return m 44 | } 45 | 46 | // Run collect and send specs 47 | func (m *Manager) Run(ctx context.Context, initialInterval, interval time.Duration) error { 48 | d := initialInterval 49 | loop: 50 | for { 51 | select { 52 | case <-ctx.Done(): 53 | break loop 54 | case <-time.After(d): 55 | err := m.collectAndPost(ctx) 56 | if err != nil { 57 | // do not break the loop with spec posting error 58 | logger.Warningf("failed to update host spec: %s", err) 59 | } 60 | d = interval 61 | } 62 | } 63 | return nil 64 | } 65 | 66 | // Get collect specs 67 | func (m *Manager) Get(ctx context.Context) (*mackerel.CreateHostParam, error) { 68 | var param mackerel.CreateHostParam 69 | name, err := os.Hostname() 70 | if err != nil { 71 | return nil, err 72 | } 73 | meta, hostname, err := m.collector.collect(ctx) 74 | if err != nil { 75 | return nil, err 76 | } 77 | if hostname != "" { 78 | name = hostname 79 | } 80 | param.Name = name 81 | param.Meta = meta 82 | param.Meta.AgentName = BuildUserAgent(m.version, m.revision) 83 | param.Meta.AgentVersion = m.version + "-container" 84 | param.Meta.AgentRevision = m.revision 85 | ifaces, err := getInterfaces() 86 | if err != nil { 87 | return nil, err 88 | } 89 | param.Interfaces = ifaces 90 | param.CustomIdentifier = m.customIdentifier 91 | return ¶m, nil 92 | } 93 | 94 | // BuildUserAgent creates User-Agent, also used in agent-name of host's meta 95 | func BuildUserAgent(version, revision string) string { 96 | return fmt.Sprintf("mackerel-container-agent/%s (Revision %s)", version, revision) 97 | } 98 | 99 | // SetHostID sets host id 100 | func (m *Manager) SetHostID(hostID string) { 101 | m.sender.setHostID(hostID) 102 | } 103 | 104 | func (m *Manager) collectAndPost(ctx context.Context) error { 105 | param, err := m.Get(ctx) 106 | if err != nil { 107 | return err 108 | } 109 | updateParam := mackerel.UpdateHostParam(*param) 110 | updateParam.Checks = m.checks 111 | return m.sender.post(&updateParam) 112 | } 113 | 114 | // SetChecks sets check configs 115 | func (m *Manager) SetChecks(checks []mackerel.CheckConfig) { 116 | m.checks = checks 117 | } 118 | -------------------------------------------------------------------------------- /metric/plugin_test.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | 8 | mackerel "github.com/mackerelio/mackerel-client-go" 9 | 10 | "github.com/mackerelio/mackerel-container-agent/cmdutil" 11 | "github.com/mackerelio/mackerel-container-agent/config" 12 | ) 13 | 14 | func TestPlugin_Generate(t *testing.T) { 15 | ctx := context.Background() 16 | g := NewPluginGenerator(&config.MetricPlugin{ 17 | Name: "dice", 18 | Command: cmdutil.CommandString("../example/dice.sh"), 19 | }) 20 | values, err := g.Generate(ctx) 21 | if err != nil { 22 | t.Errorf("should not raise error: %v", err) 23 | } 24 | if len(values) != 2 { 25 | t.Errorf("values should have size 2 but got: %v", values) 26 | } 27 | value := values["custom.dice.d6"] 28 | if value < 1 || 6 < value { 29 | t.Errorf("dice.d6 should be 1 to 6 but got: %v", values) 30 | } 31 | value = values["custom.dice.d20"] 32 | if value < 1 || 20 < value { 33 | t.Errorf("dice.d20 should be 1 to 20 but got: %v", values) 34 | } 35 | } 36 | 37 | func TestPlugin_GetGraphDefs(t *testing.T) { 38 | ctx := context.Background() 39 | g := NewPluginGenerator(&config.MetricPlugin{ 40 | Name: "dice", 41 | Command: cmdutil.CommandString("../example/dice.sh"), 42 | }) 43 | graphDefs, err := g.GetGraphDefs(ctx) 44 | if err != nil { 45 | t.Errorf("should not raise error: %v", err) 46 | } 47 | expectedGraphDefs := []*mackerel.GraphDefsParam{ 48 | &mackerel.GraphDefsParam{ 49 | Name: "custom.dice", 50 | DisplayName: "My Dice", 51 | Unit: "integer", 52 | Metrics: []*mackerel.GraphDefsMetric{ 53 | &mackerel.GraphDefsMetric{ 54 | Name: "custom.dice.d6", 55 | DisplayName: "Die 6", 56 | IsStacked: false, 57 | }, 58 | &mackerel.GraphDefsMetric{ 59 | Name: "custom.dice.d20", 60 | DisplayName: "Die 20", 61 | IsStacked: false, 62 | }, 63 | }, 64 | }, 65 | } 66 | if !reflect.DeepEqual(graphDefs, expectedGraphDefs) { 67 | t.Errorf("expected: %#v, got: %#v", expectedGraphDefs, graphDefs) 68 | } 69 | } 70 | 71 | func TestPlugin_WithEnvGenerate(t *testing.T) { 72 | ctx := context.Background() 73 | g := NewPluginGenerator(&config.MetricPlugin{ 74 | Name: "dice", 75 | Command: cmdutil.CommandString("../example/env.sh"), 76 | Env: []string{"NUM=128"}, 77 | }) 78 | values, err := g.Generate(ctx) 79 | if err != nil { 80 | t.Errorf("should not raise error: %v", err) 81 | } 82 | if len(values) != 1 { 83 | t.Errorf("values should have size 1 but got: %v", values) 84 | } 85 | value := values["custom.dice.d128"] 86 | if value < 1 || 128 < value { 87 | t.Errorf("dice.d128 should be 1 to 128 but got: %v", values) 88 | } 89 | } 90 | 91 | func TestPlugin_WithEnvGetGraphDefs(t *testing.T) { 92 | ctx := context.Background() 93 | g := NewPluginGenerator(&config.MetricPlugin{ 94 | Name: "dice", 95 | Command: cmdutil.CommandString("../example/env.sh"), 96 | Env: []string{"NUM=128"}, 97 | }) 98 | graphDefs, err := g.GetGraphDefs(ctx) 99 | if err != nil { 100 | t.Errorf("should not raise error: %v", err) 101 | } 102 | if expected := "My Dice 128"; graphDefs[0].DisplayName != expected { 103 | t.Errorf("expected: %#v, got: %#v", expected, graphDefs[0].DisplayName) 104 | } 105 | if expected := "custom.dice.d128"; graphDefs[0].Metrics[0].Name != expected { 106 | t.Errorf("expected: %#v, got: %#v", expected, graphDefs[0].Metrics[0].Name) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mackerelio/mackerel-container-agent 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | github.com/Songmu/retry v0.1.0 9 | github.com/Songmu/timeout v0.4.0 10 | github.com/aws/aws-sdk-go-v2 v1.41.0 11 | github.com/aws/aws-sdk-go-v2/config v1.32.6 12 | github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18 13 | github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 14 | github.com/docker/docker v28.5.2+incompatible 15 | github.com/mackerelio/go-osstat v0.2.6 16 | github.com/mackerelio/golib v1.2.1 17 | github.com/mackerelio/mackerel-client-go v0.39.0 18 | github.com/shirou/gopsutil/v3 v3.24.5 19 | golang.org/x/sync v0.19.0 20 | gopkg.in/yaml.v3 v3.0.1 21 | k8s.io/api v0.34.3 22 | k8s.io/apimachinery v0.34.3 23 | k8s.io/kubelet v0.34.3 24 | ) 25 | 26 | require ( 27 | github.com/Songmu/wrapcommander v0.1.0 // indirect 28 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect 29 | github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect 30 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect 31 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect 32 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect 33 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect 34 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect 35 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect 36 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect 37 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect 38 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect 39 | github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect 40 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect 41 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect 42 | github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect 43 | github.com/aws/smithy-go v1.24.0 // indirect 44 | github.com/docker/go-connections v0.4.0 // indirect 45 | github.com/docker/go-units v0.4.0 // indirect 46 | github.com/fxamacker/cbor/v2 v2.9.0 // indirect 47 | github.com/go-logr/logr v1.4.2 // indirect 48 | github.com/go-ole/go-ole v1.2.6 // indirect 49 | github.com/gogo/protobuf v1.3.2 // indirect 50 | github.com/json-iterator/go v1.1.12 // indirect 51 | github.com/kr/text v0.2.0 // indirect 52 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 53 | github.com/moby/docker-image-spec v1.3.1 // indirect 54 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 55 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 56 | github.com/opencontainers/go-digest v1.0.0 // indirect 57 | github.com/opencontainers/image-spec v1.0.2 // indirect 58 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect 59 | github.com/shoenig/go-m1cpu v0.1.6 // indirect 60 | github.com/tklauser/go-sysconf v0.3.12 // indirect 61 | github.com/tklauser/numcpus v0.6.1 // indirect 62 | github.com/x448/float16 v0.8.4 // indirect 63 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 64 | go.yaml.in/yaml/v2 v2.4.2 // indirect 65 | golang.org/x/net v0.38.0 // indirect 66 | golang.org/x/sys v0.33.0 // indirect 67 | golang.org/x/text v0.23.0 // indirect 68 | gopkg.in/inf.v0 v0.9.1 // indirect 69 | gotest.tools/v3 v3.4.0 // indirect 70 | k8s.io/klog/v2 v2.130.1 // indirect 71 | k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect 72 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 73 | sigs.k8s.io/randfill v1.0.0 // indirect 74 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect 75 | ) 76 | -------------------------------------------------------------------------------- /platform/ecs/taskmetadata/client.go: -------------------------------------------------------------------------------- 1 | package taskmetadata 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "path" 11 | "regexp" 12 | "time" 13 | 14 | dockerTypes "github.com/docker/docker/api/types/container" 15 | ecsTypes "github.com/mackerelio/mackerel-container-agent/internal/amazon-ecs-agent/agent/handlers/v2" 16 | ) 17 | 18 | const ( 19 | metadataPath = "/task" 20 | statsPath = "/task/stats" 21 | ) 22 | 23 | var timeout = 3 * time.Second 24 | 25 | // Client ... 26 | type Client struct { 27 | url *url.URL 28 | httpClient *http.Client 29 | ignoreContainer *regexp.Regexp 30 | } 31 | 32 | // NewClient creates a new Client 33 | func NewClient(metadataURI string, ignoreContainer *regexp.Regexp) (*Client, error) { 34 | u, err := url.Parse(metadataURI) 35 | if err != nil { 36 | return nil, err 37 | } 38 | dt := http.DefaultTransport.(*http.Transport) 39 | c := &Client{ 40 | url: u, 41 | httpClient: &http.Client{ 42 | Timeout: timeout, 43 | Transport: &http.Transport{ 44 | Proxy: nil, 45 | DialContext: dt.DialContext, 46 | MaxIdleConns: dt.MaxIdleConns, 47 | IdleConnTimeout: dt.IdleConnTimeout, 48 | TLSHandshakeTimeout: dt.TLSHandshakeTimeout, 49 | ExpectContinueTimeout: dt.ExpectContinueTimeout, 50 | }, 51 | }, 52 | ignoreContainer: ignoreContainer, 53 | } 54 | return c, nil 55 | } 56 | 57 | // GetTaskMetadata gets task metadata 58 | func (c *Client) GetTaskMetadata(ctx context.Context) (*ecsTypes.TaskResponse, error) { 59 | req, err := c.newRequest(metadataPath) 60 | if err != nil { 61 | return nil, err 62 | } 63 | resp, err := c.httpClient.Do(req.WithContext(ctx)) 64 | if err != nil { 65 | return nil, err 66 | } 67 | var data ecsTypes.TaskResponse 68 | if err = decodeBody(resp, &data); err != nil { 69 | return nil, err 70 | } 71 | if c.ignoreContainer != nil { 72 | containers := make([]ecsTypes.ContainerResponse, 0, len(data.Containers)) 73 | for _, container := range data.Containers { 74 | if c.ignoreContainer.MatchString(container.Name) { 75 | continue 76 | } 77 | containers = append(containers, container) 78 | } 79 | data.Containers = containers 80 | } 81 | return &data, nil 82 | } 83 | 84 | // GetTaskStats gets task stats 85 | func (c *Client) GetTaskStats(ctx context.Context) (map[string]*dockerTypes.StatsResponse, error) { 86 | req, err := c.newRequest(statsPath) 87 | if err != nil { 88 | return nil, err 89 | } 90 | resp, err := c.httpClient.Do(req.WithContext(ctx)) 91 | if err != nil { 92 | return nil, err 93 | } 94 | var all map[string]*dockerTypes.StatsResponse 95 | if err = decodeBody(resp, &all); err != nil { 96 | return nil, err 97 | } 98 | 99 | meta, err := c.GetTaskMetadata(ctx) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | res := make(map[string]*dockerTypes.StatsResponse) 105 | 106 | for _, container := range meta.Containers { 107 | if v, ok := all[container.ID]; ok { 108 | res[container.ID] = v 109 | } 110 | } 111 | 112 | return res, nil 113 | } 114 | 115 | func (c *Client) newRequest(endpoint string) (*http.Request, error) { 116 | u := *c.url 117 | u.Path = path.Join(c.url.Path, endpoint) 118 | return http.NewRequest("GET", u.String(), nil) 119 | } 120 | 121 | func decodeBody(resp *http.Response, out any) error { 122 | defer resp.Body.Close() // nolint 123 | if resp.StatusCode != http.StatusOK { 124 | body, _ := io.ReadAll(resp.Body) 125 | return fmt.Errorf("got status code %d (url: %s, body: %q)", resp.StatusCode, resp.Request.URL, body) 126 | } 127 | return json.NewDecoder(resp.Body).Decode(out) 128 | } 129 | -------------------------------------------------------------------------------- /config/loader_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "reflect" 7 | "testing" 8 | "time" 9 | 10 | "github.com/mackerelio/mackerel-container-agent/cmdutil" 11 | ) 12 | 13 | func TestLoaderLoad(t *testing.T) { 14 | file := newConfigFile(t, ` 15 | apikey: 'DUMMY APIKEY' 16 | root: '/tmp/mackerel-container-agent' 17 | `) 18 | 19 | expect := &Config{ 20 | Apibase: "", 21 | Apikey: "DUMMY APIKEY", 22 | Root: "/tmp/mackerel-container-agent", 23 | } 24 | 25 | ctx, cancel := context.WithCancel(context.Background()) 26 | defer cancel() 27 | 28 | confLoader := NewLoader(file, 0) 29 | conf, err := confLoader.Load(ctx) 30 | if err != nil { 31 | t.Fatalf("should not raise error: %v", err) 32 | } 33 | if !reflect.DeepEqual(conf, expect) { 34 | t.Errorf("expect %#v, got %#v", expect, conf) 35 | } 36 | 37 | confCh := confLoader.Start(ctx) 38 | go cancel() 39 | <-confCh 40 | } 41 | 42 | func TestLoaderStart(t *testing.T) { 43 | file := newConfigFile(t, ` 44 | apikey: 'DUMMY APIKEY' 45 | root: '/tmp/mackerel-container-agent' 46 | `) 47 | 48 | expect := &Config{ 49 | Apibase: "", 50 | Apikey: "DUMMY APIKEY", 51 | Root: "/tmp/mackerel-container-agent", 52 | } 53 | 54 | ctx, cancel := context.WithCancel(context.Background()) 55 | defer cancel() 56 | 57 | confLoader := NewLoader(file, 300*time.Millisecond) 58 | conf, err := confLoader.Load(ctx) 59 | if err != nil { 60 | t.Fatalf("should not raise error: %v", err) 61 | } 62 | if !reflect.DeepEqual(conf, expect) { 63 | t.Errorf("expect %#v, got %#v", expect, conf) 64 | } 65 | 66 | confCh := confLoader.Start(ctx) 67 | go func() { 68 | for { 69 | select { 70 | case <-confCh: 71 | cancel() 72 | return 73 | case <-ctx.Done(): 74 | return 75 | } 76 | } 77 | }() 78 | 79 | errCh := make(chan error) 80 | go func() { 81 | time.Sleep(800 * time.Millisecond) 82 | errCh <- os.WriteFile(file, []byte(` 83 | apikey: 'DUMMY APIKEY 2' 84 | root: '/tmp/mackerel-container-agent' 85 | plugin: 86 | metrics: 87 | mysql: 88 | command: mackerel-plugin-mysql 89 | `), 0600) 90 | }() 91 | 92 | expect2 := &Config{ 93 | Apibase: "", 94 | Apikey: "DUMMY APIKEY 2", 95 | Root: "/tmp/mackerel-container-agent", 96 | MetricPlugins: []*MetricPlugin{ 97 | &MetricPlugin{ 98 | Name: "mysql", 99 | Command: cmdutil.CommandString("mackerel-plugin-mysql"), 100 | }, 101 | }, 102 | } 103 | 104 | <-ctx.Done() 105 | if err := <-errCh; err != nil { 106 | t.Fatalf("should not raise error (failed to write new config file): %v", err) 107 | } 108 | 109 | conf, err = confLoader.Load(ctx) 110 | if err != nil { 111 | t.Fatalf("should not raise error: %v", err) 112 | } 113 | if !reflect.DeepEqual(conf, expect2) { 114 | t.Errorf("expect %#v, got %#v", expect2, conf) 115 | } 116 | } 117 | 118 | func TestLoaderStartCancel(t *testing.T) { 119 | file := newConfigFile(t, ` 120 | apikey: 'DUMMY APIKEY' 121 | root: '/tmp/mackerel-container-agent' 122 | `) 123 | 124 | expect := &Config{ 125 | Apibase: "", 126 | Apikey: "DUMMY APIKEY", 127 | Root: "/tmp/mackerel-container-agent", 128 | } 129 | 130 | ctx, cancel := context.WithCancel(context.Background()) 131 | defer cancel() 132 | 133 | confLoader := NewLoader(file, 300*time.Millisecond) 134 | conf, err := confLoader.Load(ctx) 135 | if err != nil { 136 | t.Fatalf("should not raise error: %v", err) 137 | } 138 | if !reflect.DeepEqual(conf, expect) { 139 | t.Errorf("expect %#v, got %#v", expect, conf) 140 | } 141 | 142 | confCh := confLoader.Start(ctx) 143 | 144 | go func() { 145 | time.Sleep(500 * time.Millisecond) 146 | cancel() 147 | }() 148 | 149 | <-confCh // when the context is done, loader should stop the polling loop 150 | <-ctx.Done() 151 | } 152 | -------------------------------------------------------------------------------- /platform/kubernetes/metric.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | "time" 7 | 8 | kubernetesTypes "k8s.io/api/core/v1" 9 | kubeletTypes "k8s.io/kubelet/pkg/apis/stats/v1alpha1" 10 | 11 | mackerel "github.com/mackerelio/mackerel-client-go" 12 | 13 | "github.com/mackerelio/mackerel-container-agent/metric" 14 | "github.com/mackerelio/mackerel-container-agent/metric/hostinfo" 15 | "github.com/mackerelio/mackerel-container-agent/platform/kubernetes/kubelet" 16 | ) 17 | 18 | type metricGenerator struct { 19 | client kubelet.Client 20 | hostInfoGenerator hostinfo.Generator 21 | hostMemTotal *float64 22 | hostNumCores *float64 23 | prevStats *kubeletTypes.PodStats 24 | prevTime time.Time 25 | } 26 | 27 | func newMetricGenerator(client kubelet.Client, hostinfoGenerator hostinfo.Generator) *metricGenerator { 28 | return &metricGenerator{ 29 | client: client, 30 | hostInfoGenerator: hostinfoGenerator, 31 | } 32 | } 33 | 34 | func (g *metricGenerator) Generate(ctx context.Context) (metric.Values, error) { 35 | stats, err := g.client.GetPodStats(ctx) 36 | if err != nil { 37 | return nil, err 38 | } 39 | if g.hostMemTotal == nil || g.hostNumCores == nil { 40 | memTotal, cpuCores, err := g.hostInfoGenerator.Generate() 41 | if err != nil { 42 | return nil, err 43 | } 44 | if g.hostMemTotal == nil { 45 | g.hostMemTotal = &memTotal 46 | } 47 | if g.hostNumCores == nil { 48 | g.hostNumCores = &cpuCores 49 | } 50 | } 51 | 52 | now := time.Now() 53 | if g.prevStats == nil || g.prevTime.Before(now.Add(-10*time.Minute)) { 54 | g.prevStats = stats 55 | g.prevTime = now 56 | return nil, nil 57 | } 58 | 59 | pod, err := g.client.GetPod(ctx) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | delta := now.Sub(g.prevTime) 65 | metrics := make(metric.Values) 66 | for _, prevContainer := range g.prevStats.Containers { 67 | for _, currContainer := range stats.Containers { 68 | if currContainer.Name == prevContainer.Name { 69 | name := metric.SanitizeMetricKey(currContainer.Name) 70 | metrics["container.cpu."+name+".usage"] = calculateCPUMetrics(&prevContainer, &currContainer, delta) 71 | if currContainer.Memory.WorkingSetBytes != nil { 72 | metrics["container.memory."+name+".usage"] = float64(*currContainer.Memory.WorkingSetBytes) 73 | } 74 | for _, c := range pod.Spec.Containers { 75 | if c.Name == currContainer.Name { 76 | metrics["container.cpu."+name+".limit"] = g.getCPULimit(&c) 77 | metrics["container.memory."+name+".limit"] = g.getMermoryLimit(&c) 78 | break 79 | } 80 | } 81 | } 82 | } 83 | } 84 | 85 | g.prevStats = stats 86 | g.prevTime = now 87 | 88 | return metrics, nil 89 | } 90 | 91 | func (g *metricGenerator) getMermoryLimit(container *kubernetesTypes.Container) float64 { 92 | limit := *g.hostMemTotal 93 | if v, ok := container.Resources.Limits["memory"]; ok && v.Format != "" { 94 | i, _ := v.AsInt64() 95 | limit = float64(i) 96 | } 97 | return limit 98 | } 99 | 100 | func (g *metricGenerator) getCPULimit(container *kubernetesTypes.Container) float64 { 101 | limit := *g.hostNumCores * 100 102 | if v, ok := container.Resources.Limits["cpu"]; ok { 103 | if d := v.AsDec(); d != nil { 104 | if v, err := strconv.ParseFloat(d.String(), 64); err == nil { 105 | limit = v * 100 106 | } 107 | } 108 | } 109 | return limit 110 | } 111 | 112 | func calculateCPUMetrics(prev, curr *kubeletTypes.ContainerStats, delta time.Duration) float64 { 113 | return float64(*curr.CPU.UsageCoreNanoSeconds-*prev.CPU.UsageCoreNanoSeconds) / float64(delta.Nanoseconds()) * 100 114 | } 115 | 116 | func (g *metricGenerator) GetGraphDefs(context.Context) ([]*mackerel.GraphDefsParam, error) { 117 | return nil, nil 118 | } 119 | -------------------------------------------------------------------------------- /platform/kubernetes/spec_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "os" 7 | "reflect" 8 | "testing" 9 | "time" 10 | 11 | kubernetesTypes "k8s.io/api/core/v1" 12 | 13 | mackerel "github.com/mackerelio/mackerel-client-go" 14 | 15 | "github.com/mackerelio/mackerel-container-agent/platform" 16 | "github.com/mackerelio/mackerel-container-agent/platform/kubernetes/kubelet" 17 | agentSpec "github.com/mackerelio/mackerel-container-agent/spec" 18 | ) 19 | 20 | func TestGenerateSpec(t *testing.T) { 21 | client := kubelet.NewMockClient( 22 | kubelet.MockGetPod(func(context.Context) (*kubernetesTypes.Pod, error) { 23 | raw, err := os.ReadFile("kubelet/testdata/pods.json") 24 | if err != nil { 25 | return nil, err 26 | } 27 | var podList kubernetesTypes.PodList 28 | if err := json.Unmarshal(raw, &podList); err != nil { 29 | return nil, err 30 | } 31 | for _, pod := range podList.Items { 32 | if pod.Namespace == "default" && pod.Name == "myapp" { 33 | return &pod, nil 34 | } 35 | } 36 | return nil, nil 37 | }), 38 | ) 39 | generator := newSpecGenerator(client) 40 | got, err := generator.Generate(context.Background()) 41 | if err != nil { 42 | t.Errorf("should not raise error: %v", err) 43 | } 44 | v, ok := got.(*agentSpec.CloudHostname) 45 | if !ok { 46 | t.Errorf("Generate() should return *spec.CloudHostname, got %T", got) 47 | } 48 | 49 | expected := &agentSpec.CloudHostname{ 50 | Cloud: &mackerel.Cloud{ 51 | Provider: string(platform.Kubernetes), 52 | MetaData: &podSpec{ 53 | // Metadata 54 | Name: "myapp", 55 | UID: "ec8c70d0-93c8-11e8-a6ea-025000000001", 56 | Namespace: "default", 57 | ResouceVersion: "112885", 58 | Labels: map[string]string{"app": "myapp"}, 59 | OwnerReferences: []ownerReference(nil), 60 | 61 | // Spec 62 | HostNetwork: false, 63 | NodeName: "docker-for-desktop", 64 | Containers: []container{ 65 | container{ 66 | Name: "nginx", 67 | Image: "nginx:alpine", 68 | Command: []string(nil), 69 | Args: []string(nil), 70 | Ports: []containerPort{ 71 | containerPort{ 72 | ContainerPort: 80, 73 | HostPort: 0, 74 | Name: "httpd", 75 | Protocol: "TCP", 76 | HostIP: "", 77 | }, 78 | }, 79 | ContainerID: "docker://651bc0955f4074659ffb96321c941a92c8da879f700e3d8f5cfb52ea4a95f4a8", 80 | }, 81 | container{ 82 | Name: "mackerel-container-agent", 83 | Image: "mackerel-container-agent:0.0.1", 84 | Command: []string(nil), 85 | Args: []string(nil), 86 | Resources: resourceRequirements{ 87 | Limits: resourceList{ 88 | "cpu": "250m", 89 | "memory": "128Mi", 90 | }, 91 | }, 92 | Ports: []containerPort(nil), 93 | ContainerID: "docker://85ecaca37f3f9a9b79388bc4b6706b824fcd038259dd4d786b7ce853326d00e9", 94 | }, 95 | }, 96 | 97 | // Status 98 | Phase: "Running", 99 | HostIP: "192.168.65.3", 100 | PodIP: "10.1.0.6", 101 | Conditions: []podCondition{ 102 | podCondition{ 103 | Type: "Initialized", 104 | Status: "True", 105 | }, 106 | podCondition{ 107 | Type: "Ready", 108 | Status: "True", 109 | }, 110 | podCondition{ 111 | Type: "PodScheduled", 112 | Status: "True", 113 | }, 114 | }, 115 | StartTime: func() *time.Time { t, _ := time.Parse(time.RFC3339, "2018-07-30T07:19:40Z"); t = t.Local(); return &t }(), 116 | }, 117 | }, 118 | Hostname: "myapp", 119 | } 120 | 121 | if !reflect.DeepEqual(v, expected) { 122 | t.Errorf("Generate() expected %#v, got %#v", expected, v) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /metric/plugin.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "strconv" 8 | "strings" 9 | 10 | mackerel "github.com/mackerelio/mackerel-client-go" 11 | 12 | "github.com/mackerelio/mackerel-container-agent/cmdutil" 13 | "github.com/mackerelio/mackerel-container-agent/config" 14 | ) 15 | 16 | const ( 17 | pluginPrefix = "custom." 18 | pluginMetaEnvName = "MACKEREL_AGENT_PLUGIN_META" 19 | pluginMetaHeadline = "# mackerel-agent-plugin" 20 | ) 21 | 22 | type pluginGenerator struct { 23 | config.MetricPlugin 24 | } 25 | 26 | // NewPluginGenerator creates a new plugin generator 27 | func NewPluginGenerator(p *config.MetricPlugin) Generator { 28 | return &pluginGenerator{*p} 29 | } 30 | 31 | // Generate generates metric values 32 | func (g *pluginGenerator) Generate(ctx context.Context) (Values, error) { 33 | env := append(g.Env, pluginMetaEnvName+"=") 34 | var masked_env []string 35 | for _, v := range env { 36 | key := strings.Split(v, "=")[0] 37 | value := strings.Split(v, "=")[1] 38 | masked_env = append(masked_env, key+"="+config.MaskEnvValue(value)) 39 | } 40 | logger.Debugf("plugin %s command: %s env: %+v", g.Name, g.Command, masked_env) 41 | stdout, stderr, _, err := cmdutil.RunCommand(ctx, g.Command, g.User, env, g.Timeout) 42 | 43 | if stderr != "" { 44 | logger.Infof("plugin %s (%s): %q", g.Name, g.Command, stderr) 45 | } 46 | if err != nil { 47 | return nil, fmt.Errorf("plugin %s (%s): %w", g.Name, g.Command, err) 48 | } 49 | 50 | values := make(Values) 51 | for _, line := range strings.Split(stdout, "\n") { 52 | // key, value, timestamp 53 | xs := strings.Fields(line) 54 | if len(xs) < 3 { 55 | continue 56 | } 57 | value, err := strconv.ParseFloat(xs[1], 64) 58 | if err != nil { 59 | logger.Warningf("plugin %s (%s): failed to parse value: %s", g.Name, g.Command, err) 60 | continue 61 | } 62 | values[pluginPrefix+xs[0]] = value 63 | } 64 | 65 | return values, nil 66 | } 67 | 68 | type pluginMeta struct { 69 | Graphs map[string]struct { 70 | Label string 71 | Unit string 72 | Metrics []struct { 73 | Name string 74 | Label string 75 | Stacked bool 76 | } 77 | } 78 | } 79 | 80 | // GetGraphDefs gets graph definitions 81 | func (g *pluginGenerator) GetGraphDefs(ctx context.Context) ([]*mackerel.GraphDefsParam, error) { 82 | env := append(g.Env, pluginMetaEnvName+"=1") 83 | stdout, stderr, _, err := cmdutil.RunCommand(ctx, g.Command, g.User, env, g.Timeout) 84 | 85 | if stderr != "" { 86 | logger.Infof("plugin %s (%s): %q", g.Name, g.Command, stderr) 87 | } 88 | if err != nil { 89 | return nil, fmt.Errorf("plugin %s (%s): %w", g.Name, g.Command, err) 90 | } 91 | 92 | xs := strings.SplitN(stdout, "\n", 2) 93 | if len(xs) < 2 || !strings.HasPrefix(xs[0], pluginMetaHeadline) { 94 | logger.Infof("plugin %s: invalid plugin meta output: %q", g.Name, stdout) 95 | return nil, nil 96 | } 97 | 98 | var conf pluginMeta 99 | if err = json.Unmarshal([]byte(xs[1]), &conf); err != nil { 100 | return nil, fmt.Errorf("plugin %s: failed to decode plugin meta: %w", g.Name, err) 101 | } 102 | 103 | var graphDefs []*mackerel.GraphDefsParam 104 | for key, graph := range conf.Graphs { 105 | graphDef := mackerel.GraphDefsParam{ 106 | Name: pluginPrefix + key, 107 | DisplayName: graph.Label, 108 | Unit: graph.Unit, 109 | } 110 | if graphDef.Unit == "" { 111 | graphDef.Unit = "float" 112 | } 113 | for _, metric := range graph.Metrics { 114 | graphDef.Metrics = append(graphDef.Metrics, &mackerel.GraphDefsMetric{ 115 | Name: pluginPrefix + key + "." + metric.Name, 116 | DisplayName: metric.Label, 117 | IsStacked: metric.Stacked, 118 | }) 119 | } 120 | graphDefs = append(graphDefs, &graphDef) 121 | } 122 | 123 | return graphDefs, nil 124 | } 125 | -------------------------------------------------------------------------------- /agent/platform.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "regexp" 8 | 9 | "github.com/mackerelio/mackerel-container-agent/platform" 10 | "github.com/mackerelio/mackerel-container-agent/platform/ecs" 11 | "github.com/mackerelio/mackerel-container-agent/platform/kubernetes" 12 | "github.com/mackerelio/mackerel-container-agent/platform/kubernetes/kubelet" 13 | "github.com/mackerelio/mackerel-container-agent/platform/none" 14 | ) 15 | 16 | // NewPlatform creates a new container platform 17 | func NewPlatform(ctx context.Context, ignoreContainer *regexp.Regexp) (platform.Platform, error) { 18 | p, err := getEnvValue("MACKEREL_CONTAINER_PLATFORM") 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | switch platform.Type(p) { 24 | 25 | case platform.ECSAwsvpc, platform.ECSv3: 26 | logger.Warningf("%q platform is deprecated. Please use %q platform", p, platform.ECS) 27 | fallthrough 28 | 29 | // backward compatibility: MACKEREL_CONTAINER_PLATFORM allows multiple values 30 | case platform.ECS, platform.Fargate, platform.ECSAnywhere: 31 | metadataURI, err := getEnvValue("ECS_CONTAINER_METADATA_URI") 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | executionEnv := os.Getenv("AWS_EXECUTION_ENV") 37 | if executionEnv == "" { 38 | // experimental 39 | // If there is no environment variable, the corresponding as ECS Anywhere 40 | // on AWS ECS Anywhere Instance, `AWS_EXECUTION_ENV` is not defined. 41 | executionEnv = ecs.ExecutionEnvECSExternal 42 | } 43 | 44 | return ecs.NewECSPlatform(ctx, metadataURI, executionEnv, ignoreContainer) 45 | 46 | case platform.Kubernetes: 47 | useReadOnlyPort := true 48 | insecureTLS := false 49 | host, err := getEnvValue("MACKEREL_KUBERNETES_KUBELET_HOST") 50 | if err != nil { 51 | return nil, err 52 | } 53 | port, err := getEnvValue("MACKEREL_KUBERNETES_KUBELET_READ_ONLY_PORT") 54 | if err != nil { 55 | port = kubelet.DefaultReadOnlyPort 56 | } 57 | if port == "0" { 58 | useReadOnlyPort = false 59 | port, err = getEnvValue("MACKEREL_KUBERNETES_KUBELET_PORT") 60 | if err != nil { 61 | port = kubelet.DefaultPort 62 | } 63 | _, err := getEnvValue("MACKEREL_KUBERNETES_KUBELET_INSECURE_TLS") 64 | if err == nil { 65 | insecureTLS = true 66 | } 67 | } 68 | namespace, err := getEnvValue("MACKEREL_KUBERNETES_NAMESPACE") 69 | if err != nil { 70 | return nil, err 71 | } 72 | podName, err := getEnvValue("MACKEREL_KUBERNETES_POD_NAME") 73 | if err != nil { 74 | return nil, err 75 | } 76 | return kubernetes.NewKubernetesPlatform(host, port, useReadOnlyPort, insecureTLS, namespace, podName, ignoreContainer) 77 | 78 | case platform.EKSOnFargate: 79 | host, err := getEnvValue("KUBERNETES_SERVICE_HOST") 80 | if err != nil { 81 | return nil, err 82 | } 83 | port, err := getEnvValue("KUBERNETES_SERVICE_PORT") 84 | if err != nil { 85 | port = "443" 86 | } 87 | namespace, err := getEnvValue("MACKEREL_KUBERNETES_NAMESPACE") 88 | if err != nil { 89 | return nil, err 90 | } 91 | podName, err := getEnvValue("MACKEREL_KUBERNETES_POD_NAME") 92 | if err != nil { 93 | return nil, err 94 | } 95 | nodeName, err := getEnvValue("MACKEREL_KUBERNETES_NODE_NAME") 96 | if err != nil { 97 | return nil, err 98 | } 99 | return kubernetes.NewEKSOnFargatePlatform(host, port, namespace, podName, nodeName, ignoreContainer) 100 | 101 | // for testing & debugging on local machine 102 | case platform.None: 103 | return none.NewNonePlatform() 104 | 105 | default: 106 | return nil, fmt.Errorf("%q platform is invalid. please check your MACKEREL_CONTAINER_PLATFORM", p) 107 | } 108 | } 109 | 110 | func getEnvValue(name string) (string, error) { 111 | value := os.Getenv(name) 112 | if value == "" { 113 | return value, fmt.Errorf("%s environment variable is not set", name) 114 | } 115 | return value, nil 116 | } 117 | -------------------------------------------------------------------------------- /agent/run.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/Songmu/retry" 8 | "golang.org/x/sync/errgroup" 9 | 10 | "github.com/mackerelio/mackerel-container-agent/api" 11 | "github.com/mackerelio/mackerel-container-agent/check" 12 | "github.com/mackerelio/mackerel-container-agent/config" 13 | "github.com/mackerelio/mackerel-container-agent/metric" 14 | "github.com/mackerelio/mackerel-container-agent/platform" 15 | "github.com/mackerelio/mackerel-container-agent/probe" 16 | "github.com/mackerelio/mackerel-container-agent/spec" 17 | ) 18 | 19 | var ( 20 | metricsInterval = time.Minute 21 | checkInterval = time.Minute 22 | specInterval = time.Hour 23 | specInitialInterval = 5 * time.Minute 24 | waitStatusRunningInterval = 3 * time.Second 25 | hostIDInitialRetryInterval = 1 * time.Second 26 | ) 27 | 28 | func run( 29 | ctx context.Context, 30 | client api.Client, 31 | metricManager *metric.Manager, 32 | checkManager *check.Manager, 33 | specManager *spec.Manager, 34 | pform platform.Platform, 35 | conf *config.Config, 36 | ) (func(), error) { 37 | specManager.SetChecks(checkManager.Configs()) 38 | eg, ctx := errgroup.WithContext(ctx) 39 | 40 | hostResolver := newHostResolver(client, conf.HostIDStore, conf.Root) 41 | eg.Go(func() error { 42 | var duration time.Duration 43 | loop: 44 | for { 45 | select { 46 | case <-time.After(duration): 47 | if pform.StatusRunning(ctx) { 48 | break loop 49 | } 50 | if duration == 0 { 51 | duration = waitStatusRunningInterval 52 | } 53 | logger.Infof("wait for the platform status to be running") 54 | case <-ctx.Done(): 55 | return nil 56 | } 57 | } 58 | 59 | if conf.ReadinessProbe != nil { 60 | if err := probe.Wait(ctx, probe.NewProbe(conf.ReadinessProbe)); err != nil { 61 | return nil 62 | } 63 | } 64 | 65 | hostParam, err := specManager.Get(ctx) 66 | if err != nil { 67 | return err 68 | } 69 | hostParam.RoleFullnames = conf.Roles 70 | hostParam.DisplayName = conf.DisplayName 71 | hostParam.Memo = conf.Memo 72 | hostParam.Checks = checkManager.Configs() 73 | 74 | duration = hostIDInitialRetryInterval 75 | for { 76 | select { 77 | case <-time.After(duration): 78 | host, retryHostID, err := hostResolver.getHost(hostParam) 79 | if retryHostID { 80 | logger.Infof("retry to find host: %s", err) 81 | if duration *= 2; duration > 10*time.Minute { 82 | duration = 10 * time.Minute 83 | } 84 | continue 85 | } 86 | if err != nil { 87 | return err 88 | } 89 | logger.Infof("start the agent: host id = %s, host name = %s", host.ID, hostParam.Name) 90 | if conf.HostStatusOnStart != "" && host.Status != string(conf.HostStatusOnStart) { 91 | err = retry.Retry(5, 3*time.Second, func() error { 92 | return client.UpdateHostStatus(host.ID, string(conf.HostStatusOnStart)) 93 | }) 94 | if err != nil { 95 | logger.Warningf("failed to update host status on start: %s", err) 96 | } 97 | } 98 | err = retry.Retry(5, 3*time.Second, func() error { 99 | return metricManager.CollectAndPostGraphDefs(ctx) 100 | }) 101 | if err != nil { 102 | logger.Warningf("failed to post graph definitions: %s", err) 103 | } 104 | metricManager.SetHostID(host.ID) 105 | checkManager.SetHostID(host.ID) 106 | specManager.SetHostID(host.ID) 107 | return nil 108 | case <-ctx.Done(): 109 | return nil 110 | } 111 | } 112 | }) 113 | 114 | eg.Go(func() error { 115 | return metricManager.Run(ctx, metricsInterval) 116 | }) 117 | 118 | eg.Go(func() error { 119 | return checkManager.Run(ctx, checkInterval) 120 | }) 121 | 122 | eg.Go(func() error { 123 | return specManager.Run(ctx, specInitialInterval, specInterval) 124 | }) 125 | 126 | return func() { 127 | if err := retire(client, hostResolver); err != nil { 128 | logger.Warningf("failed to retire: %s", err) 129 | } 130 | }, eg.Wait() 131 | } 132 | -------------------------------------------------------------------------------- /check/manager_test.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "reflect" 7 | "testing" 8 | "time" 9 | 10 | mackerel "github.com/mackerelio/mackerel-client-go" 11 | 12 | "github.com/mackerelio/mackerel-container-agent/api" 13 | ) 14 | 15 | func TestManager_Configs(t *testing.T) { 16 | client := api.NewMockClient() 17 | manager := NewManager(createMockGenerators(), client) 18 | expected := []mackerel.CheckConfig{ 19 | {Name: "g1", Memo: "g1 memo"}, 20 | {Name: "g2", Memo: "g2 memo"}, 21 | {Name: "g3", Memo: "g3 memo"}, 22 | } 23 | if !reflect.DeepEqual(expected, manager.Configs()) { 24 | t.Errorf("expected: %#v, got: %#v", expected, manager.Configs()) 25 | } 26 | } 27 | 28 | func TestManagerRun(t *testing.T) { 29 | hostID := "abcde" 30 | var postedReports []*mackerel.CheckReports 31 | client := api.NewMockClient( 32 | api.MockCreateHost(func(param *mackerel.CreateHostParam) (string, error) { 33 | return hostID, nil 34 | }), 35 | api.MockPostCheckReports(func(reports *mackerel.CheckReports) error { 36 | postedReports = append(postedReports, reports) 37 | return nil 38 | }), 39 | ) 40 | manager := NewManager(createMockGenerators(), client) 41 | manager.SetHostID(hostID) 42 | 43 | ctx, cancel := context.WithTimeout(context.Background(), 340*time.Millisecond) 44 | defer cancel() 45 | err := manager.Run(ctx, 50*time.Millisecond) 46 | if err != nil { 47 | t.Errorf("err should be nil but got: %+v", err) 48 | } 49 | if expected := 4; len(postedReports) != expected { 50 | t.Errorf("posted reports should have size %d but got: %d", expected, len(postedReports)) 51 | } 52 | report := postedReports[2].Reports[0] 53 | if expected := "g2"; report.Name != expected { 54 | t.Errorf("report name should be %q but got: %q", expected, report.Name) 55 | } 56 | if expected := "g2 critical"; report.Message != expected { 57 | t.Errorf("report message should be %q but got: %q", expected, report.Message) 58 | } 59 | if expected := mackerel.CheckStatusCritical; report.Status != expected { 60 | t.Errorf("report status should be %v but got: %v", expected, report.Status) 61 | } 62 | if expected := mackerel.NewCheckSourceHost(hostID); !reflect.DeepEqual(report.Source, expected) { 63 | t.Errorf("report source should be %v but got: %v", expected, report.Source) 64 | } 65 | } 66 | 67 | func TestManagerRun_Retry(t *testing.T) { 68 | hostID := "abcde" 69 | var postedReports []*mackerel.CheckReports 70 | client := api.NewMockClient( 71 | api.MockCreateHost(func(param *mackerel.CreateHostParam) (string, error) { 72 | return hostID, nil 73 | }), 74 | api.MockPostCheckReports(func(reports *mackerel.CheckReports) error { 75 | return &mackerel.APIError{StatusCode: http.StatusInternalServerError} 76 | }), 77 | ) 78 | go func() { 79 | time.Sleep(120 * time.Millisecond) 80 | client.ApplyOption( 81 | api.MockPostCheckReports(func(reports *mackerel.CheckReports) error { 82 | postedReports = append(postedReports, reports) 83 | return nil 84 | }), 85 | ) 86 | }() 87 | manager := NewManager(createMockGenerators(), client) 88 | manager.SetHostID(hostID) 89 | 90 | // This test is flaky so we should sometimes retry. 91 | expectedNums := []int{4} 92 | for i := 0; i < 3; i++ { 93 | ctx, cancel := context.WithTimeout(context.Background(), 190*time.Millisecond) 94 | err := manager.Run(ctx, 50*time.Millisecond) 95 | cancel() 96 | if err != nil { 97 | t.Errorf("err should be nil but got: %+v", err) 98 | break 99 | } 100 | nums := reportCounts(postedReports) 101 | if reflect.DeepEqual(nums, expectedNums) { 102 | break 103 | } 104 | t.Logf("got %v; retry", nums) 105 | } 106 | nums := reportCounts(postedReports) 107 | if !reflect.DeepEqual(nums, expectedNums) { 108 | t.Errorf("posted reports should have size %v but got: %v", expectedNums, nums) 109 | } 110 | } 111 | 112 | func reportCounts(a []*mackerel.CheckReports) []int { 113 | nums := make([]int, len(a)) 114 | for i, r := range a { 115 | nums[i] = len(r.Reports) 116 | } 117 | return nums 118 | } 119 | -------------------------------------------------------------------------------- /platform/ecs/spec.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | import ( 4 | "context" 5 | "path" 6 | 7 | "github.com/aws/aws-sdk-go-v2/aws/arn" 8 | ecsTypes "github.com/mackerelio/mackerel-container-agent/internal/amazon-ecs-agent/agent/handlers/v2" 9 | 10 | mackerel "github.com/mackerelio/mackerel-client-go" 11 | 12 | agentSpec "github.com/mackerelio/mackerel-container-agent/spec" 13 | ) 14 | 15 | // TaskMetadataGetter interface fetch ECS task metadata 16 | type TaskMetadataGetter interface { 17 | GetTaskMetadata(context.Context) (*ecsTypes.TaskResponse, error) 18 | } 19 | 20 | type specGenerator struct { 21 | client TaskMetadataGetter 22 | provider provider 23 | } 24 | 25 | func newSpecGenerator(client TaskMetadataGetter, provider provider) *specGenerator { 26 | return &specGenerator{ 27 | client: client, 28 | provider: provider, 29 | } 30 | } 31 | 32 | func (g *specGenerator) Generate(ctx context.Context) (any, error) { 33 | meta, err := g.client.GetTaskMetadata(ctx) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | spec, err := generateSpec(meta) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return &agentSpec.CloudHostname{ 44 | Cloud: &mackerel.Cloud{ 45 | Provider: string(g.provider), 46 | MetaData: spec, 47 | }, 48 | Hostname: spec.Task, 49 | }, nil 50 | } 51 | 52 | func generateSpec(meta *ecsTypes.TaskResponse) (*taskSpec, error) { 53 | taskID, err := getTaskID(meta.TaskARN) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | spec := &taskSpec{ 59 | Cluster: meta.Cluster, 60 | Task: taskID, 61 | TaskARN: meta.TaskARN, 62 | TaskFamily: meta.Family, 63 | TaskVersion: meta.Revision, 64 | DesiredStatus: meta.DesiredStatus, 65 | KnownStatus: meta.KnownStatus, 66 | PullStartedAt: meta.PullStartedAt, 67 | PullStoppedAt: meta.PullStoppedAt, 68 | ExecutionStoppedAt: meta.ExecutionStoppedAt, 69 | } 70 | 71 | if meta.Containers != nil { 72 | containers := make([]containerSpec, len(meta.Containers)) 73 | spec.Containers = containers 74 | 75 | for i, c := range meta.Containers { 76 | containers[i] = containerSpec{ 77 | DockerID: c.ID, 78 | DockerName: c.DockerName, 79 | Name: c.Name, 80 | Image: c.Image, 81 | ImageID: c.ImageID, 82 | Labels: c.Labels, 83 | DesiredStatus: c.DesiredStatus, 84 | KnownStatus: c.KnownStatus, 85 | ExitCode: c.ExitCode, 86 | CreatedAt: c.CreatedAt, 87 | StartedAt: c.StartedAt, 88 | FinishedAt: c.FinishedAt, 89 | Type: c.Type, 90 | Limits: limitSpec{ 91 | CPU: c.Limits.CPU, 92 | Memory: c.Limits.Memory, 93 | }, 94 | } 95 | 96 | if c.Ports != nil { 97 | ports := make([]portSpec, len(c.Ports)) 98 | for j, p := range c.Ports { 99 | ports[j] = portSpec{ 100 | ContainerPort: p.ContainerPort, 101 | HostPort: p.HostPort, 102 | Protocol: p.Protocol, 103 | } 104 | } 105 | containers[i].Ports = ports 106 | } 107 | 108 | if c.Networks != nil { 109 | networks := make([]networkSpec, len(c.Networks)) 110 | for j, n := range c.Networks { 111 | networks[j] = networkSpec{ 112 | NetworkMode: n.NetworkMode, 113 | IPv4Addresses: n.IPv4Addresses, 114 | IPv6Addresses: n.IPv6Addresses, 115 | } 116 | } 117 | containers[i].Networks = networks 118 | } 119 | 120 | if h := c.Health; h != nil { 121 | containers[i].Health = &healthStatus{ 122 | ExitCode: h.ExitCode, 123 | Output: h.Output, 124 | Since: h.Since, 125 | Status: int32(h.Status), 126 | } 127 | } 128 | } 129 | } 130 | 131 | if meta.Limits != nil { 132 | spec.Limits = limitSpec{ 133 | CPU: meta.Limits.CPU, 134 | Memory: meta.Limits.Memory, 135 | } 136 | } 137 | 138 | return spec, nil 139 | } 140 | 141 | func getTaskID(taskARN string) (string, error) { 142 | a, err := arn.Parse(taskARN) 143 | if err != nil { 144 | return "", err 145 | } 146 | return path.Base(a.Resource), nil 147 | } 148 | -------------------------------------------------------------------------------- /platform/kubernetes/metric_test.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "os" 7 | "reflect" 8 | "testing" 9 | 10 | kubernetesTypes "k8s.io/api/core/v1" 11 | "k8s.io/apimachinery/pkg/api/resource" 12 | kubeletTypes "k8s.io/kubelet/pkg/apis/stats/v1alpha1" 13 | 14 | "github.com/mackerelio/mackerel-container-agent/metric" 15 | "github.com/mackerelio/mackerel-container-agent/metric/hostinfo" 16 | "github.com/mackerelio/mackerel-container-agent/platform/kubernetes/kubelet" 17 | ) 18 | 19 | func TestGenerateStats(t *testing.T) { 20 | ctx := context.Background() 21 | client := kubelet.NewMockClient( 22 | kubelet.MockGetPod(func(context.Context) (*kubernetesTypes.Pod, error) { 23 | raw, err := os.ReadFile("kubelet/testdata/pods.json") 24 | if err != nil { 25 | return nil, err 26 | } 27 | var podList kubernetesTypes.PodList 28 | if err := json.Unmarshal(raw, &podList); err != nil { 29 | return nil, err 30 | } 31 | for _, pod := range podList.Items { 32 | if pod.Namespace == "default" && pod.Name == "myapp" { 33 | return &pod, nil 34 | } 35 | } 36 | return nil, nil 37 | }), 38 | kubelet.MockGetPodStats(func(context.Context) (*kubeletTypes.PodStats, error) { 39 | raw, err := os.ReadFile("kubelet/testdata/summary.json") 40 | if err != nil { 41 | return nil, err 42 | } 43 | var summary kubeletTypes.Summary 44 | if err := json.Unmarshal(raw, &summary); err != nil { 45 | return nil, err 46 | } 47 | for _, pod := range summary.Pods { 48 | if pod.PodRef.Namespace == "default" && pod.PodRef.Name == "myapp" { 49 | return &pod, nil 50 | } 51 | } 52 | return nil, nil 53 | }), 54 | ) 55 | generator := newMetricGenerator(client, hostinfo.NewMockGenerator(3876802560.0, 8.0, nil)) 56 | _, err := generator.Generate(ctx) // Store metrics to generator.prevStats. 57 | if err != nil { 58 | t.Errorf("Generate() should not raise error: %v", err) 59 | } 60 | got, err := generator.Generate(ctx) 61 | if err != nil { 62 | t.Errorf("Generate() should not raise error: %v", err) 63 | } 64 | expected := metric.Values{ 65 | "container.cpu.mackerel-container-agent.usage": 0.0, // Result is 0 because use the same data. 66 | "container.cpu.nginx.usage": 0.0, // Result is 0 because use the same data. 67 | "container.cpu.mackerel-container-agent.limit": 25.0, 68 | "container.cpu.nginx.limit": 800.0, // mockCpuCores * 100 69 | "container.memory.mackerel-container-agent.usage": 1.8608128e+07, 70 | "container.memory.nginx.usage": 1.941504e+06, 71 | "container.memory.mackerel-container-agent.limit": 134217728.0, // 128MiB 72 | "container.memory.nginx.limit": 3876802560.0, // mockMemTotal 73 | } 74 | if !reflect.DeepEqual(expected, got) { 75 | t.Errorf("Generate() expected %v, got %v", expected, got) 76 | } 77 | } 78 | 79 | func TestGetMemoryLimit(t *testing.T) { 80 | hostMemTotal := 2096058368.0 81 | name := "dummy" 82 | tests := []struct { 83 | quantity string 84 | expected float64 85 | }{ 86 | { 87 | "", 88 | hostMemTotal, 89 | }, 90 | { 91 | "134217728", 92 | 134217728.0, 93 | }, 94 | { 95 | "128e6", 96 | 128000000.0, 97 | }, 98 | { 99 | "128M", 100 | 128000000.0, 101 | }, 102 | { 103 | "128Mi", 104 | 134217728.0, 105 | }, 106 | { 107 | "1G", 108 | 1000000000.0, 109 | }, 110 | { 111 | "1Gi", 112 | 1073741824.0, 113 | }, 114 | } 115 | g := &metricGenerator{ 116 | hostMemTotal: &hostMemTotal, 117 | } 118 | for _, tc := range tests { 119 | q, _ := resource.ParseQuantity(tc.quantity) 120 | rn := kubernetesTypes.ResourceName("memory") 121 | container := kubernetesTypes.Container{ 122 | Name: name, 123 | Resources: kubernetesTypes.ResourceRequirements{ 124 | Limits: kubernetesTypes.ResourceList{rn: q}, 125 | }, 126 | } 127 | got := g.getMermoryLimit(&container) 128 | if got != tc.expected { 129 | t.Errorf("getMermoryLimit() expected %.1f, got %.1f", tc.expected, got) 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /internal/amazon-ecs-agent/agent/handlers/v2/response.go: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"). You may 4 | // not use this file except in compliance with the License. A copy of the 5 | // License is located at 6 | // 7 | // http://aws.amazon.com/apache2.0/ 8 | // 9 | // or in the "license" file accompanying this file. This file is distributed 10 | // on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either 11 | // express or implied. See the License for the specific language governing 12 | // permissions and limitations under the License. 13 | 14 | package v2 15 | 16 | import ( 17 | "time" 18 | 19 | apicontainer "github.com/mackerelio/mackerel-container-agent/internal/amazon-ecs-agent/agent/api/container" 20 | containermetadata "github.com/mackerelio/mackerel-container-agent/internal/amazon-ecs-agent/agent/containermetadata" 21 | "github.com/mackerelio/mackerel-container-agent/internal/amazon-ecs-agent/agent/handlers/v1" 22 | ) 23 | 24 | // TaskResponse defines the schema for the task response JSON object 25 | type TaskResponse struct { 26 | Cluster string `json:"Cluster"` 27 | TaskARN string `json:"TaskARN"` 28 | Family string `json:"Family"` 29 | Revision string `json:"Revision"` 30 | DesiredStatus string `json:"DesiredStatus,omitempty"` 31 | KnownStatus string `json:"KnownStatus"` 32 | Containers []ContainerResponse `json:"Containers,omitempty"` 33 | Limits *LimitsResponse `json:"Limits,omitempty"` 34 | PullStartedAt *time.Time `json:"PullStartedAt,omitempty"` 35 | PullStoppedAt *time.Time `json:"PullStoppedAt,omitempty"` 36 | ExecutionStoppedAt *time.Time `json:"ExecutionStoppedAt,omitempty"` 37 | AvailabilityZone string `json:"AvailabilityZone,omitempty"` 38 | TaskTags map[string]string `json:"TaskTags,omitempty"` 39 | ContainerInstanceTags map[string]string `json:"ContainerInstanceTags,omitempty"` 40 | LaunchType string `json:"LaunchType,omitempty"` 41 | Errors []ErrorResponse `json:"Errors,omitempty"` 42 | } 43 | 44 | // ContainerResponse defines the schema for the container response 45 | // JSON object 46 | type ContainerResponse struct { 47 | ID string `json:"DockerId"` 48 | Name string `json:"Name"` 49 | DockerName string `json:"DockerName"` 50 | Image string `json:"Image"` 51 | ImageID string `json:"ImageID"` 52 | Ports []v1.PortResponse `json:"Ports,omitempty"` 53 | Labels map[string]string `json:"Labels,omitempty"` 54 | DesiredStatus string `json:"DesiredStatus"` 55 | KnownStatus string `json:"KnownStatus"` 56 | ExitCode *int `json:"ExitCode,omitempty"` 57 | Limits LimitsResponse `json:"Limits"` 58 | CreatedAt *time.Time `json:"CreatedAt,omitempty"` 59 | StartedAt *time.Time `json:"StartedAt,omitempty"` 60 | FinishedAt *time.Time `json:"FinishedAt,omitempty"` 61 | Type string `json:"Type"` 62 | Networks []containermetadata.Network `json:"Networks,omitempty"` 63 | Health *apicontainer.HealthStatus `json:"Health,omitempty"` 64 | Volumes []v1.VolumeResponse `json:"Volumes,omitempty"` 65 | LogDriver string `json:"LogDriver,omitempty"` 66 | LogOptions map[string]string `json:"LogOptions,omitempty"` 67 | ContainerARN string `json:"ContainerARN,omitempty"` 68 | } 69 | 70 | // LimitsResponse defines the schema for task/cpu limits response 71 | // JSON object 72 | type LimitsResponse struct { 73 | CPU *float64 `json:"CPU,omitempty"` 74 | Memory *int64 `json:"Memory,omitempty"` 75 | } 76 | 77 | // ErrorResponse defined the schema for error response 78 | // JSON object 79 | type ErrorResponse struct { 80 | ErrorField string `json:"ErrorField,omitempty"` 81 | ErrorCode string `json:"ErrorCode,omitempty"` 82 | ErrorMessage string `json:"ErrorMessage,omitempty"` 83 | StatusCode int `json:"StatusCode,omitempty"` 84 | RequestID string `json:"RequestId,omitempty"` 85 | ResourceARN string `json:"ResourceARN,omitempty"` 86 | } 87 | -------------------------------------------------------------------------------- /platform/kubernetes/kubelet/client.go: -------------------------------------------------------------------------------- 1 | package kubelet 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "path" 11 | "regexp" 12 | 13 | kubernetesTypes "k8s.io/api/core/v1" 14 | kubeletTypes "k8s.io/kubelet/pkg/apis/stats/v1alpha1" 15 | ) 16 | 17 | // Client interface gets metadata and stats 18 | type Client interface { 19 | GetPod(context.Context) (*kubernetesTypes.Pod, error) 20 | GetPodStats(context.Context) (*kubeletTypes.PodStats, error) 21 | } 22 | 23 | const ( 24 | // DefaultPort represents Kubelet port 25 | DefaultPort = "10250" 26 | // DefaultReadOnlyPort represents Kubelet read-only port 27 | DefaultReadOnlyPort = "10255" 28 | 29 | podsPath = "/pods" 30 | statsPath = "/stats/summary" 31 | ) 32 | 33 | type client struct { 34 | url *url.URL 35 | httpClient *http.Client 36 | namespace string 37 | name string 38 | token string 39 | ignoreContainer *regexp.Regexp 40 | } 41 | 42 | // NewClient creates a new Client 43 | func NewClient(httpClient *http.Client, token, baseURL, namespace, name string, ignoreContainer *regexp.Regexp) (Client, error) { 44 | u, err := url.Parse(baseURL) 45 | if err != nil { 46 | return nil, err 47 | } 48 | return &client{ 49 | url: u, 50 | namespace: namespace, 51 | name: name, 52 | httpClient: httpClient, 53 | token: token, 54 | ignoreContainer: ignoreContainer, 55 | }, nil 56 | } 57 | 58 | // GetPod gets pod 59 | func (c *client) GetPod(ctx context.Context) (*kubernetesTypes.Pod, error) { 60 | req, err := c.newRequest(podsPath) 61 | if err != nil { 62 | return nil, err 63 | } 64 | resp, err := c.httpClient.Do(req.WithContext(ctx)) 65 | if err != nil { 66 | return nil, err 67 | } 68 | var podList kubernetesTypes.PodList 69 | if err = decodeBody(resp, &podList); err != nil { 70 | return nil, err 71 | } 72 | 73 | var pod *kubernetesTypes.Pod 74 | for _, p := range podList.Items { 75 | if p.Namespace == c.namespace && p.Name == c.name { 76 | pod = &p 77 | break 78 | } 79 | } 80 | if pod == nil { 81 | return nil, fmt.Errorf("pod %s.%s not found", c.namespace, c.name) 82 | } 83 | 84 | if c.ignoreContainer != nil { 85 | containers := make([]kubernetesTypes.Container, 0, len(pod.Spec.Containers)) 86 | for _, container := range pod.Spec.Containers { 87 | if c.ignoreContainer.MatchString(container.Name) { 88 | continue 89 | } 90 | containers = append(containers, container) 91 | } 92 | pod.Spec.Containers = containers 93 | } 94 | 95 | return pod, nil 96 | } 97 | 98 | // GetPodStats gets pod stats 99 | func (c *client) GetPodStats(ctx context.Context) (*kubeletTypes.PodStats, error) { 100 | req, err := c.newRequest(statsPath) 101 | if err != nil { 102 | return nil, err 103 | } 104 | resp, err := c.httpClient.Do(req.WithContext(ctx)) 105 | if err != nil { 106 | return nil, err 107 | } 108 | var summary kubeletTypes.Summary 109 | if err = decodeBody(resp, &summary); err != nil { 110 | return nil, err 111 | } 112 | 113 | var stats *kubeletTypes.PodStats 114 | for _, pod := range summary.Pods { 115 | if pod.PodRef.Namespace == c.namespace && pod.PodRef.Name == c.name { 116 | stats = &pod 117 | break 118 | } 119 | } 120 | if stats == nil { 121 | return nil, fmt.Errorf("pod %s.%s not found", c.namespace, c.name) 122 | } 123 | 124 | if c.ignoreContainer != nil { 125 | containers := make([]kubeletTypes.ContainerStats, 0, len(stats.Containers)) 126 | for _, container := range stats.Containers { 127 | if c.ignoreContainer.MatchString(container.Name) { 128 | continue 129 | } 130 | containers = append(containers, container) 131 | } 132 | stats.Containers = containers 133 | } 134 | 135 | return stats, nil 136 | } 137 | 138 | func (c *client) newRequest(endpoint string) (*http.Request, error) { 139 | u := *c.url 140 | u.Path = path.Join(c.url.Path, endpoint) 141 | req, err := http.NewRequest("GET", u.String(), nil) 142 | if err != nil { 143 | return nil, err 144 | } 145 | if c.token != "" { 146 | req.Header.Set("Authorization", "Bearer "+c.token) 147 | } 148 | return req, nil 149 | } 150 | 151 | func decodeBody(resp *http.Response, out any) error { 152 | defer resp.Body.Close() // nolint 153 | if resp.StatusCode != http.StatusOK { 154 | body, _ := io.ReadAll(resp.Body) 155 | return fmt.Errorf("got status code %d (url: %s, body: %q)", resp.StatusCode, resp.Request.URL, body) 156 | } 157 | return json.NewDecoder(resp.Body).Decode(out) 158 | } 159 | -------------------------------------------------------------------------------- /agent/agent.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "os" 8 | "os/signal" 9 | "strconv" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/mackerelio/golib/logging" 14 | mackerel "github.com/mackerelio/mackerel-client-go" 15 | 16 | "github.com/mackerelio/mackerel-container-agent/check" 17 | "github.com/mackerelio/mackerel-container-agent/config" 18 | "github.com/mackerelio/mackerel-container-agent/metric" 19 | "github.com/mackerelio/mackerel-container-agent/spec" 20 | ) 21 | 22 | var logger = logging.GetLogger("agent") 23 | 24 | // Agent interface 25 | type Agent interface { 26 | Run([]string) error 27 | } 28 | 29 | // NewAgent creates a new Mackerel agent 30 | func NewAgent(version, revision string) Agent { 31 | return &agent{version, revision} 32 | } 33 | 34 | type agent struct { 35 | version, revision string 36 | } 37 | 38 | func (a *agent) Run(_ []string) error { 39 | sigCh := make(chan os.Signal, 1) 40 | signal.Notify(sigCh, syscall.SIGHUP) 41 | retires := make([]func(), 0, 1) 42 | defer func() { 43 | for _, retire := range retires { 44 | retire() 45 | } 46 | }() 47 | confLoader, err := createConfLoader() 48 | if err != nil { 49 | return err 50 | } 51 | for { 52 | ctx, cancel := context.WithCancel(context.Background()) 53 | defer cancel() 54 | conf, err := confLoader.Load(ctx) 55 | if err != nil { 56 | return err 57 | } 58 | errCh := make(chan error) 59 | go func() { 60 | retire, err := a.start(ctx, conf) 61 | if retire != nil { 62 | retires = append(retires, retire) 63 | } 64 | errCh <- err 65 | }() 66 | confCh := confLoader.Start(ctx) 67 | select { 68 | case sig := <-sigCh: 69 | logger.Infof("reload config: signal = %s", sig) 70 | cancel() 71 | case <-confCh: 72 | cancel() 73 | case err := <-errCh: 74 | return err 75 | } 76 | } 77 | } 78 | 79 | func createConfLoader() (*config.Loader, error) { 80 | var pollingDuration time.Duration 81 | if durationMinutesStr := os.Getenv( 82 | "MACKEREL_AGENT_CONFIG_POLLING_DURATION_MINUTES", 83 | ); durationMinutesStr != "" { 84 | durationMinutes, err := strconv.Atoi(durationMinutesStr) 85 | if err != nil { 86 | return nil, fmt.Errorf("failed to parse config polling duration: %w", err) 87 | } 88 | pollingDuration = time.Duration(durationMinutes) * time.Minute 89 | } 90 | return config.NewLoader(os.Getenv("MACKEREL_AGENT_CONFIG"), pollingDuration), nil 91 | } 92 | 93 | func (a *agent) start(ctx context.Context, conf *config.Config) (func(), error) { 94 | ctx, cancel := context.WithCancel(ctx) 95 | defer cancel() 96 | 97 | client := mackerel.NewClient(conf.Apikey) 98 | if conf.Apibase != "" { 99 | baseURL, err := url.Parse(conf.Apibase) 100 | if err != nil { 101 | return nil, err 102 | } 103 | client.BaseURL = baseURL 104 | } 105 | client.UserAgent = spec.BuildUserAgent(a.version, a.revision) 106 | if conf.ReadinessProbe != nil && conf.ReadinessProbe.HTTP != nil { 107 | conf.ReadinessProbe.HTTP.UserAgent = client.UserAgent 108 | } 109 | 110 | sigCh := make(chan os.Signal, 1) 111 | signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) 112 | defer signal.Stop(sigCh) 113 | var sig os.Signal 114 | go func() { 115 | select { 116 | case sig = <-sigCh: 117 | cancel() 118 | case <-ctx.Done(): 119 | } 120 | }() 121 | defer func() { 122 | if sig != nil { 123 | logger.Infof("stop the agent: signal = %s", sig) 124 | } 125 | }() 126 | 127 | pform, err := NewPlatform(ctx, conf.IgnoreContainer.Regexp) 128 | if err != nil { 129 | return nil, err 130 | } 131 | 132 | customIdentifier, err := pform.GetCustomIdentifier(ctx) 133 | if err != nil { 134 | logger.Warningf("failed to get custom identifier: %s", err) 135 | } 136 | 137 | metricGenerators := pform.GetMetricGenerators() 138 | for _, mp := range conf.MetricPlugins { 139 | metricGenerators = append(metricGenerators, metric.NewPluginGenerator(mp)) 140 | } 141 | metricManager := metric.NewManager(metricGenerators, client) 142 | 143 | var checkGenerators []check.Generator 144 | for _, cp := range conf.CheckPlugins { 145 | checkGenerators = append(checkGenerators, check.NewPluginGenerator(cp)) 146 | } 147 | checkManager := check.NewManager(checkGenerators, client) 148 | 149 | specGenerators := pform.GetSpecGenerators() 150 | specManager := spec.NewManager(specGenerators, client). 151 | WithVersion(a.version, a.revision). 152 | WithCustomIdentifier(customIdentifier) 153 | 154 | return run(ctx, client, metricManager, checkManager, specManager, pform, conf) 155 | } 156 | -------------------------------------------------------------------------------- /platform/ecs/metric_test.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "reflect" 9 | "testing" 10 | 11 | dockerTypes "github.com/docker/docker/api/types/container" 12 | ecsTypes "github.com/mackerelio/mackerel-container-agent/internal/amazon-ecs-agent/agent/handlers/v2" 13 | 14 | "github.com/mackerelio/mackerel-container-agent/metric" 15 | "github.com/mackerelio/mackerel-container-agent/metric/hostinfo" 16 | ) 17 | 18 | type mockTaskMetadataEndpointClient struct { 19 | metadataPath string 20 | statsPath string 21 | } 22 | 23 | func (m *mockTaskMetadataEndpointClient) GetTaskMetadata(ctx context.Context) (*ecsTypes.TaskResponse, error) { 24 | f, err := os.Open(m.metadataPath) 25 | if err != nil { 26 | return nil, err 27 | } 28 | defer f.Close() // nolint 29 | var res ecsTypes.TaskResponse 30 | if err := json.NewDecoder(f).Decode(&res); err != nil { 31 | return nil, err 32 | } 33 | return &res, nil 34 | } 35 | 36 | func (m *mockTaskMetadataEndpointClient) GetTaskStats(ctx context.Context) (map[string]*dockerTypes.StatsResponse, error) { 37 | f, err := os.Open(m.statsPath) 38 | if err != nil { 39 | return nil, err 40 | } 41 | defer f.Close() // nolint 42 | 43 | var res map[string]*dockerTypes.StatsResponse 44 | if err := json.NewDecoder(f).Decode(&res); err != nil { 45 | return nil, err 46 | } 47 | 48 | return res, nil 49 | } 50 | 51 | func TestGenerateMetric(t *testing.T) { 52 | tests := []struct { 53 | mode string 54 | expected metric.Values 55 | }{ 56 | { 57 | "ec2_bridge", 58 | metric.Values{ 59 | "container.cpu.mackerel-container-agent.usage": 0.0, // Result is 0 because use the same data. 60 | "container.cpu.mackerel-container-agent.limit": 25.0, 61 | "container.memory.mackerel-container-agent.usage": 1.2111872e+07, 62 | "container.memory.mackerel-container-agent.limit": 134217728.0, // 128MiB 63 | "interface.mackerel-container-agent-eth0.rxBytes.delta": 0, 64 | "interface.mackerel-container-agent-eth0.txBytes.delta": 0, 65 | }, 66 | }, 67 | { 68 | "ec2_host", 69 | metric.Values{ 70 | "container.cpu.mackerel-container-agent.usage": 0.0, // Result is 0 because use the same data. 71 | "container.cpu.mackerel-container-agent.limit": 25.0, 72 | "container.memory.mackerel-container-agent.usage": 1.048576e+06, 73 | "container.memory.mackerel-container-agent.limit": 134217728.0, // 128MiB 74 | }, 75 | }, 76 | { 77 | "ec2_awsvpc", 78 | metric.Values{ 79 | "container.cpu.mackerel-container-agent.usage": 0.0, // Result is 0 because use the same data. 80 | "container.cpu.mackerel-container-agent.limit": 25.0, 81 | "container.cpu._internal_ecs_pause.usage": 0.0, // Result is 0 because use the same data. 82 | "container.cpu._internal_ecs_pause.limit": 25.0, 83 | "container.memory.mackerel-container-agent.usage": 1.1567104e+07, 84 | "container.memory.mackerel-container-agent.limit": 134217728.0, // 128MiB 85 | "container.memory._internal_ecs_pause.limit": 2.68435456e+08, 86 | "container.memory._internal_ecs_pause.usage": 573440, 87 | }, 88 | }, 89 | { 90 | "fargate", 91 | metric.Values{ 92 | "container.cpu.mackerel-container-agent.usage": 0.0, // Result is 0 because use the same data. 93 | "container.cpu.mackerel-container-agent.limit": 25.0, 94 | "container.cpu._internal_ecs_pause.usage": 0.0, // Result is 0 because use the same data. 95 | "container.cpu._internal_ecs_pause.limit": 25.0, 96 | "container.memory.mackerel-container-agent.usage": 1.1567104e+07, 97 | "container.memory.mackerel-container-agent.limit": 134217728.0, // 128MiB 98 | "container.memory._internal_ecs_pause.limit": 2.68435456e+08, 99 | "container.memory._internal_ecs_pause.usage": 573440, 100 | }, 101 | }, 102 | } 103 | 104 | mock := &mockTaskMetadataEndpointClient{} 105 | ctx := context.Background() 106 | 107 | for _, tc := range tests { 108 | mock.metadataPath = fmt.Sprintf("taskmetadata/testdata/metadata_%s.json", tc.mode) 109 | mock.statsPath = fmt.Sprintf("taskmetadata/testdata/stats_%s.json", tc.mode) 110 | g := newMetricGenerator(mock, hostinfo.NewMockGenerator(3876802560.0, 8.0, nil)) 111 | 112 | _, err := g.Generate(ctx) 113 | if err != nil { 114 | t.Errorf("Generate() should not raise error: %v", err) 115 | } 116 | 117 | got, err := g.Generate(ctx) 118 | if err != nil { 119 | t.Errorf("Generate() should not raise error: %v", err) 120 | } 121 | 122 | if !reflect.DeepEqual(tc.expected, got) { 123 | t.Errorf("Generate() expected %v, got %v", tc.expected, got) 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /spec/manager_test.go: -------------------------------------------------------------------------------- 1 | package spec 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | mackerel "github.com/mackerelio/mackerel-client-go" 9 | 10 | "github.com/mackerelio/mackerel-container-agent/api" 11 | ) 12 | 13 | func createMockSpecGenerators() []Generator { 14 | return []Generator{ 15 | NewMockGenerator(nil, nil), 16 | } 17 | } 18 | 19 | func TestManagerRun(t *testing.T) { 20 | hostID := "abcde" 21 | var updatedCount int 22 | client := api.NewMockClient( 23 | api.MockUpdateHost(func(id string, param *mackerel.UpdateHostParam) (string, error) { 24 | updatedCount++ 25 | if hostID != id { 26 | t.Fatal("inconsistent host id") 27 | } 28 | if expected := "mackerel-container-agent/x.y.z (Revision abc)"; param.Meta.AgentName != expected { 29 | t.Errorf("name should be %q but got: %q", expected, param.Meta.AgentName) 30 | } 31 | if expected := "x.y.z-container"; param.Meta.AgentVersion != expected { 32 | t.Errorf("version should be %q but got: %q", expected, param.Meta.AgentVersion) 33 | } 34 | if expected := "abc"; param.Meta.AgentRevision != expected { 35 | t.Errorf("revision should be %q but got: %q", expected, param.Meta.AgentRevision) 36 | } 37 | return hostID, nil 38 | }), 39 | ) 40 | manager := NewManager(createMockSpecGenerators(), client).WithVersion("x.y.z", "abc") 41 | manager.SetHostID(hostID) 42 | 43 | ctx, cancel := context.WithTimeout(context.Background(), 480*time.Millisecond) 44 | defer cancel() 45 | err := manager.Run(ctx, 10*time.Millisecond, 100*time.Millisecond) 46 | if err != nil { 47 | t.Errorf("err should be nil but got: %+v", err) 48 | } 49 | // This test is flaky so we should check the count with an accuracy. 50 | const ( 51 | expected = 5 52 | accuracy = 1 53 | ) 54 | if updatedCount < expected-accuracy || updatedCount > expected+accuracy { 55 | t.Errorf("update host api is called %d times (expected: %d times with accuracy %d)", updatedCount, expected, accuracy) 56 | } 57 | } 58 | 59 | func TestManagerRun_LazyHostID(t *testing.T) { 60 | hostID := "abcde" 61 | var updatedCount int 62 | client := api.NewMockClient( 63 | api.MockUpdateHost(func(id string, param *mackerel.UpdateHostParam) (string, error) { 64 | updatedCount++ 65 | if hostID != id { 66 | t.Fatal("inconsistent host id") 67 | } 68 | if expected := "mackerel-container-agent/x.y.z (Revision abc)"; param.Meta.AgentName != expected { 69 | t.Errorf("name should be %q but got: %q", expected, param.Meta.AgentName) 70 | } 71 | if expected := "x.y.z-container"; param.Meta.AgentVersion != expected { 72 | t.Errorf("version should be %q but got: %q", expected, param.Meta.AgentVersion) 73 | } 74 | if expected := "abc"; param.Meta.AgentRevision != expected { 75 | t.Errorf("revision should be %q but got: %q", expected, param.Meta.AgentRevision) 76 | } 77 | return hostID, nil 78 | }), 79 | ) 80 | manager := NewManager(createMockSpecGenerators(), client).WithVersion("x.y.z", "abc") 81 | 82 | ctx, cancel := context.WithTimeout(context.Background(), 480*time.Millisecond) 83 | defer cancel() 84 | go func() { 85 | time.Sleep(140 * time.Millisecond) 86 | manager.SetHostID(hostID) 87 | }() 88 | err := manager.Run(ctx, 10*time.Millisecond, 100*time.Millisecond) 89 | if err != nil { 90 | t.Errorf("err should be nil but got: %+v", err) 91 | } 92 | // This test is flaky so we should check the count with an accuracy. 93 | const ( 94 | expected = 3 95 | accuracy = 1 96 | ) 97 | if updatedCount < expected-accuracy || updatedCount > expected+accuracy { 98 | t.Errorf("update host api is called %d times (expected: %d times with accuracy %d)", updatedCount, expected, accuracy) 99 | } 100 | } 101 | 102 | func TestManagerRun_Hostname(t *testing.T) { 103 | hostID := "abcde" 104 | var updatedCount int 105 | client := api.NewMockClient( 106 | api.MockUpdateHost(func(id string, param *mackerel.UpdateHostParam) (string, error) { 107 | updatedCount++ 108 | if hostID != id { 109 | t.Fatal("inconsistent host id") 110 | } 111 | if expected := "abcde012345"; param.Name != expected { 112 | t.Errorf("host name should be %q but got: %q", expected, param.Name) 113 | } 114 | return hostID, nil 115 | }), 116 | ) 117 | manager := NewManager([]Generator{ 118 | NewMockGenerator(&CloudHostname{ 119 | Cloud: nil, 120 | Hostname: "abcde012345", 121 | }, nil), 122 | }, client).WithVersion("x.y.z", "abc") 123 | manager.SetHostID(hostID) 124 | 125 | ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) 126 | defer cancel() 127 | err := manager.Run(ctx, 10*time.Millisecond, 100*time.Millisecond) 128 | if err != nil { 129 | t.Errorf("err should be nil but got: %+v", err) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /check/plugin_test.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | "testing" 7 | "time" 8 | 9 | mackerel "github.com/mackerelio/mackerel-client-go" 10 | 11 | "github.com/mackerelio/mackerel-container-agent/cmdutil" 12 | "github.com/mackerelio/mackerel-container-agent/config" 13 | ) 14 | 15 | func TestPlugin_Generate(t *testing.T) { 16 | ctx := context.Background() 17 | g := NewPluginGenerator(&config.CheckPlugin{ 18 | Name: "dice", 19 | Command: cmdutil.CommandString("../example/check-dice.sh"), 20 | }) 21 | result, err := g.Generate(ctx) 22 | if err != nil { 23 | t.Errorf("should not raise error: %v", err) 24 | } 25 | if expected := "dice"; result.name != expected { 26 | t.Errorf("name should be %q but got: %q", expected, result.name) 27 | } 28 | n, err := strconv.Atoi(result.message) 29 | if err != nil { 30 | t.Errorf("should not raise error: %v", err) 31 | } 32 | if n == 6 { 33 | if expected := mackerel.CheckStatusCritical; result.status != expected { 34 | t.Errorf("status should be %v but got: %v", expected, result.status) 35 | } 36 | } else if n == 4 || n == 5 { 37 | if expected := mackerel.CheckStatusWarning; result.status != expected { 38 | t.Errorf("status should be %v but got: %v", expected, result.status) 39 | } 40 | } else if 1 <= n && n <= 3 { 41 | if expected := mackerel.CheckStatusOK; result.status != expected { 42 | t.Errorf("status should be %v but got: %v", expected, result.status) 43 | } 44 | } else { 45 | t.Errorf("unexpected message: %v", result.message) 46 | } 47 | } 48 | 49 | func TestPlugin_Generate_Unknown(t *testing.T) { 50 | ctx := context.Background() 51 | g := NewPluginGenerator(&config.CheckPlugin{ 52 | Name: "unknown", 53 | Command: cmdutil.CommandString("../example/check-unknown.sh"), 54 | }) 55 | result, err := g.Generate(ctx) 56 | if err != nil { 57 | t.Errorf("should not raise error: %v", err) 58 | } 59 | if expected := "unknown"; result.name != expected { 60 | t.Errorf("name should be %q but got: %q", expected, result.name) 61 | } 62 | if expected := mackerel.CheckStatusUnknown; result.status != expected { 63 | t.Errorf("status should be %v but got: %v", expected, result.status) 64 | } 65 | } 66 | 67 | func TestPlugin_Generate_Timeout(t *testing.T) { 68 | ctx := context.Background() 69 | g := NewPluginGenerator(&config.CheckPlugin{ 70 | Name: "timeout", 71 | Command: cmdutil.CommandString("sleep 10"), 72 | Timeout: 10 * time.Millisecond, 73 | }) 74 | result, err := g.Generate(ctx) 75 | if err != nil { 76 | t.Errorf("should not raise error: %v", err) 77 | } 78 | if expected := "timeout"; result.name != expected { 79 | t.Errorf("name should be %q but got: %q", expected, result.name) 80 | } 81 | if expected := mackerel.CheckStatusUnknown; result.status != expected { 82 | t.Errorf("status should be %v but got: %v", expected, result.status) 83 | } 84 | if expected := "command timed out"; result.message != expected { 85 | t.Errorf("message should be %v but got: %v", expected, result.message) 86 | } 87 | } 88 | 89 | func TestPlugin_Generate_ok_ok(t *testing.T) { 90 | ctx := context.Background() 91 | g := NewPluginGenerator(&config.CheckPlugin{ 92 | Name: "ok", 93 | Command: cmdutil.CommandString("printf ok"), 94 | }) 95 | result, err := g.Generate(ctx) 96 | if err != nil { 97 | t.Errorf("should not raise error: %v", err) 98 | } 99 | if expected := "ok"; result.name != expected { 100 | t.Errorf("name should be %q but got: %q", expected, result.name) 101 | } 102 | if expected := mackerel.CheckStatusOK; result.status != expected { 103 | t.Errorf("status should be %v but got: %v", expected, result.status) 104 | } 105 | 106 | result, err = g.Generate(ctx) 107 | if err != nil { 108 | t.Errorf("should not raise error: %v", err) 109 | } 110 | if result != nil { 111 | t.Errorf("result should be nil but got: %v", result) 112 | } 113 | } 114 | 115 | func TestPlugin_Generate_Env(t *testing.T) { 116 | ctx := context.Background() 117 | g := NewPluginGenerator(&config.CheckPlugin{ 118 | Name: "ok", 119 | Command: cmdutil.CommandString("printf '%s %s' $ENV2 $ENV1"), 120 | Env: []string{"ENV1=foo", "ENV2=bar"}, 121 | }) 122 | result, err := g.Generate(ctx) 123 | if err != nil { 124 | t.Errorf("should not raise error: %v", err) 125 | } 126 | if expected := "ok"; result.name != expected { 127 | t.Errorf("name should be %q but got: %q", expected, result.name) 128 | } 129 | if expected := mackerel.CheckStatusOK; result.status != expected { 130 | t.Errorf("status should be %v but got: %v", expected, result.status) 131 | } 132 | if expected := "bar foo"; result.message != expected { 133 | t.Errorf("message should be %v but got: %v", expected, result.message) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /platform/ecs/metric.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | dockerTypes "github.com/docker/docker/api/types/container" 8 | 9 | ecsTypes "github.com/mackerelio/mackerel-container-agent/internal/amazon-ecs-agent/agent/handlers/v2" 10 | 11 | mackerel "github.com/mackerelio/mackerel-client-go" 12 | 13 | "github.com/mackerelio/mackerel-container-agent/metric" 14 | "github.com/mackerelio/mackerel-container-agent/metric/hostinfo" 15 | ) 16 | 17 | // TaskStatsGetter interface fetch ECS task stats 18 | type TaskStatsGetter interface { 19 | GetTaskStats(context.Context) (map[string]*dockerTypes.StatsResponse, error) 20 | } 21 | 22 | type metricGenerator struct { 23 | client TaskMetadataEndpointClient 24 | hostInfoGenerator hostinfo.Generator 25 | hostMemTotal *float64 26 | hostNumCores *float64 27 | prevStats map[string]*dockerTypes.StatsResponse 28 | prevTime time.Time 29 | } 30 | 31 | func newMetricGenerator(client TaskMetadataEndpointClient, hostinfoGenerator hostinfo.Generator) *metricGenerator { 32 | return &metricGenerator{ 33 | client: client, 34 | hostInfoGenerator: hostinfoGenerator, 35 | } 36 | } 37 | 38 | // Generate generates metric values 39 | func (g *metricGenerator) Generate(ctx context.Context) (metric.Values, error) { 40 | stats, err := g.client.GetTaskStats(ctx) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | if g.hostMemTotal == nil || g.hostNumCores == nil { 46 | memTotal, cpuCores, err := g.hostInfoGenerator.Generate() 47 | if err != nil { 48 | return nil, err 49 | } 50 | if g.hostMemTotal == nil { 51 | g.hostMemTotal = &memTotal 52 | } 53 | if g.hostNumCores == nil { 54 | g.hostNumCores = &cpuCores 55 | } 56 | } 57 | 58 | now := time.Now() 59 | if g.prevStats == nil || g.prevTime.Before(now.Add(-10*time.Minute)) { 60 | g.prevStats = stats 61 | g.prevTime = now 62 | return nil, nil 63 | } 64 | 65 | meta, err := g.client.GetTaskMetadata(ctx) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | timeDelta := now.Sub(g.prevTime) 71 | metricValues := make(metric.Values) 72 | for _, c := range meta.Containers { 73 | prev, ok := g.prevStats[c.ID] 74 | if !ok || prev == nil { // stats of the volume container can be nil value. 75 | continue 76 | } 77 | curr, ok := stats[c.ID] 78 | if !ok || curr == nil { // stats of the volume container can be nil value. 79 | continue 80 | } 81 | 82 | name := metric.SanitizeMetricKey(c.Name) 83 | metricValues["container.cpu."+name+".usage"] = calculateCPUMetrics(prev, curr, timeDelta) 84 | metricValues["container.cpu."+name+".limit"] = g.getCPULimit(meta) 85 | metricValues["container.memory."+name+".usage"] = calculateMemoryMetrics(curr) 86 | metricValues["container.memory."+name+".limit"] = g.getMemoryLimit(&c, meta) 87 | 88 | calculateInterfaceMetrics(name, prev, curr, timeDelta, metricValues) 89 | } 90 | 91 | g.prevStats = stats 92 | g.prevTime = now 93 | 94 | return metricValues, nil 95 | } 96 | 97 | // GetGraphDefs gets graph definitions 98 | func (g *metricGenerator) GetGraphDefs(ctx context.Context) ([]*mackerel.GraphDefsParam, error) { 99 | return nil, nil 100 | } 101 | 102 | func (g *metricGenerator) getMemoryLimit(c *ecsTypes.ContainerResponse, meta *ecsTypes.TaskResponse) float64 { 103 | if c.Limits.Memory != nil && *c.Limits.Memory != 0 { 104 | return float64(*c.Limits.Memory * MiB) 105 | } else if meta.Limits != nil && meta.Limits.Memory != nil && *meta.Limits.Memory != 0 { 106 | return float64(*meta.Limits.Memory * MiB) 107 | } 108 | return *g.hostMemTotal 109 | } 110 | 111 | func (g *metricGenerator) getCPULimit(meta *ecsTypes.TaskResponse) float64 { 112 | // Return Task CPU Limit or Host CPU Limit because Container CPU Limit means `cpu.shares`. 113 | if meta.Limits != nil && meta.Limits.CPU != nil && *meta.Limits.CPU != 0.0 { 114 | return *meta.Limits.CPU * 100 115 | } 116 | return *g.hostNumCores * 100 117 | } 118 | 119 | func calculateCPUMetrics(prev, curr *dockerTypes.StatsResponse, timeDelta time.Duration) float64 { 120 | // calculate used cpu cores. (1core == 100.0) 121 | return float64(curr.CPUStats.CPUUsage.TotalUsage-prev.CPUStats.CPUUsage.TotalUsage) / float64(timeDelta.Nanoseconds()) * 100 122 | } 123 | 124 | func calculateMemoryMetrics(stats *dockerTypes.StatsResponse) float64 { 125 | return float64(stats.MemoryStats.Usage - stats.MemoryStats.Stats["cache"]) 126 | } 127 | 128 | func calculateInterfaceMetrics(name string, prev, curr *dockerTypes.StatsResponse, timeDelta time.Duration, metricValues metric.Values) { 129 | for ifn, pv := range prev.Networks { 130 | cv, ok := curr.Networks[ifn] 131 | if !ok { 132 | continue 133 | } 134 | prefix := "interface." + name + "-" + metric.SanitizeMetricKey(ifn) 135 | metricValues[prefix+".rxBytes.delta"] = float64(cv.RxBytes-pv.RxBytes) / timeDelta.Seconds() 136 | metricValues[prefix+".txBytes.delta"] = float64(cv.TxBytes-pv.TxBytes) / timeDelta.Seconds() 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /platform/kubernetes/kubernetes.go: -------------------------------------------------------------------------------- 1 | package kubernetes 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "net" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "path" 12 | "regexp" 13 | "strings" 14 | "time" 15 | 16 | "github.com/mackerelio/golib/logging" 17 | 18 | "github.com/mackerelio/mackerel-container-agent/metric" 19 | "github.com/mackerelio/mackerel-container-agent/metric/hostinfo" 20 | "github.com/mackerelio/mackerel-container-agent/platform" 21 | "github.com/mackerelio/mackerel-container-agent/platform/kubernetes/kubelet" 22 | "github.com/mackerelio/mackerel-container-agent/spec" 23 | ) 24 | 25 | var ( 26 | logger = logging.GetLogger("kubernetes") 27 | timeout = 3 * time.Second 28 | 29 | caCertificateFile = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" 30 | tokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" 31 | ) 32 | 33 | type kubernetesPlatform struct { 34 | client kubelet.Client 35 | } 36 | 37 | // NewKubernetesPlatform creates a new Platform 38 | func NewKubernetesPlatform(kubeletHost, kubeletPort string, useReadOnlyPort, insecureTLS bool, namespace, podName string, ignoreContainer *regexp.Regexp) (platform.Platform, error) { 39 | var caCert, token []byte 40 | 41 | baseURL := &url.URL{ 42 | Scheme: "http", 43 | Host: net.JoinHostPort(kubeletHost, kubeletPort), 44 | } 45 | 46 | if !useReadOnlyPort { 47 | baseURL.Scheme = "https" 48 | 49 | var err error 50 | 51 | caCert, err = os.ReadFile(caCertificateFile) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | token, err = os.ReadFile(tokenFile) 57 | if err != nil { 58 | return nil, err 59 | } 60 | } 61 | 62 | httpClient := createHTTPClient(caCert, insecureTLS) 63 | 64 | c, err := kubelet.NewClient( 65 | httpClient, 66 | string(token), 67 | baseURL.String(), 68 | namespace, 69 | podName, 70 | ignoreContainer, 71 | ) 72 | if err != nil { 73 | return nil, err 74 | } 75 | return &kubernetesPlatform{client: c}, nil 76 | } 77 | 78 | // NewEKSOnFargatePlatform creates a new Platform 79 | // on this platform, agent accesses Kubelet via Kubernetes API (/api/v1/nodes/{nodeName}/proxy) 80 | func NewEKSOnFargatePlatform(kubernetesHost, kubernetesPort string, namespace, podName string, nodeName string, ignoreContainer *regexp.Regexp) (platform.Platform, error) { 81 | var caCert, token []byte 82 | var err error 83 | 84 | baseURL := &url.URL{ 85 | Scheme: "https", 86 | Host: net.JoinHostPort(kubernetesHost, kubernetesPort), 87 | Path: path.Join("api", "v1", "nodes", nodeName, "proxy"), 88 | } 89 | 90 | caCert, err = os.ReadFile(caCertificateFile) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | token, err = os.ReadFile(tokenFile) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | httpClient := createHTTPClient(caCert, false) 101 | 102 | c, err := kubelet.NewClient( 103 | httpClient, 104 | string(token), 105 | baseURL.String(), 106 | namespace, 107 | podName, 108 | ignoreContainer, 109 | ) 110 | if err != nil { 111 | return nil, err 112 | } 113 | return &kubernetesPlatform{client: c}, nil 114 | } 115 | 116 | // GetMetricGenerators gets metric generators 117 | func (p *kubernetesPlatform) GetMetricGenerators() []metric.Generator { 118 | return []metric.Generator{ 119 | newMetricGenerator(p.client, hostinfo.NewGenerator()), 120 | metric.NewInterfaceGenerator(), 121 | } 122 | } 123 | 124 | // GetSpecGenerators gets spec generator 125 | func (p *kubernetesPlatform) GetSpecGenerators() []spec.Generator { 126 | return []spec.Generator{ 127 | newSpecGenerator(p.client), 128 | &spec.CPUGenerator{}, 129 | } 130 | } 131 | 132 | // GetCustomIdentifier gets custom identifier 133 | func (p *kubernetesPlatform) GetCustomIdentifier(ctx context.Context) (string, error) { 134 | pod, err := p.client.GetPod(ctx) 135 | if err != nil { 136 | return "", err 137 | } 138 | return string(pod.UID) + ".kubernetes", nil 139 | } 140 | 141 | // StatusRunning reports p status is running 142 | func (p *kubernetesPlatform) StatusRunning(ctx context.Context) bool { 143 | meta, err := p.client.GetPod(ctx) 144 | if err != nil { 145 | logger.Warningf("failed to get metadata: %s", err) 146 | return false 147 | } 148 | return strings.EqualFold("running", string(meta.Status.Phase)) 149 | } 150 | 151 | func createHTTPClient(caCert []byte, insecureTLS bool) *http.Client { 152 | dt := http.DefaultTransport.(*http.Transport) 153 | tp := &http.Transport{ 154 | Proxy: nil, 155 | DialContext: dt.DialContext, 156 | MaxIdleConns: dt.MaxIdleConns, 157 | IdleConnTimeout: dt.IdleConnTimeout, 158 | TLSHandshakeTimeout: dt.TLSHandshakeTimeout, 159 | ExpectContinueTimeout: dt.ExpectContinueTimeout, 160 | } 161 | 162 | tp.TLSClientConfig = &tls.Config{ 163 | InsecureSkipVerify: insecureTLS, 164 | } 165 | 166 | if len(caCert) > 0 { 167 | certPool := x509.NewCertPool() 168 | certPool.AppendCertsFromPEM(caCert) 169 | tp.TLSClientConfig.RootCAs = certPool 170 | } 171 | 172 | return &http.Client{ 173 | Timeout: timeout, 174 | Transport: tp, 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /platform/ecs/taskmetadata/client_test.go: -------------------------------------------------------------------------------- 1 | package taskmetadata 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | "path" 12 | "regexp" 13 | "testing" 14 | ) 15 | 16 | var metadata, stats string 17 | 18 | func newServer() *httptest.Server { 19 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 | switch r.URL.RequestURI() { 21 | case metadataPath: 22 | http.ServeFile(w, r, metadata) 23 | case statsPath: 24 | http.ServeFile(w, r, stats) 25 | } 26 | }) 27 | return httptest.NewServer(handler) 28 | } 29 | 30 | func TestGetTaskMetadata(t *testing.T) { 31 | tests := []string{ 32 | "testdata/metadata_ec2_bridge.json", 33 | "testdata/metadata_ec2_host.json", 34 | "testdata/metadata_ec2_awsvpc.json", 35 | "testdata/metadata_fargate.json", 36 | } 37 | 38 | ts := newServer() 39 | defer ts.Close() 40 | 41 | for _, path := range tests { 42 | metadata = path 43 | ctx := context.Background() 44 | 45 | c, err := NewClient(ts.URL, nil) 46 | if err != nil { 47 | t.Errorf("NewClient() should not raise error: %v", err) 48 | } 49 | 50 | _, err = c.GetTaskMetadata(ctx) 51 | if err != nil { 52 | t.Errorf("GetTaskMetadata() should not raise error: %v", err) 53 | } 54 | } 55 | 56 | } 57 | 58 | func TestGetTaskStats(t *testing.T) { 59 | tests := []string{ 60 | "testdata/stats_ec2_bridge.json", 61 | "testdata/stats_ec2_host.json", 62 | "testdata/stats_ec2_awsvpc.json", 63 | "testdata/stats_fargate.json", 64 | } 65 | 66 | ts := newServer() 67 | defer ts.Close() 68 | 69 | for _, path := range tests { 70 | stats = path 71 | ctx := context.Background() 72 | 73 | c, err := NewClient(ts.URL, nil) 74 | if err != nil { 75 | t.Errorf("NewClient() should not raise error: %v", err) 76 | } 77 | 78 | _, err = c.GetTaskStats(ctx) 79 | if err != nil { 80 | t.Errorf("GetTaskStats() should not raise error: %v", err) 81 | } 82 | } 83 | } 84 | 85 | func TestIgnoreContainer(t *testing.T) { 86 | tests := []struct { 87 | ignoreContainer *regexp.Regexp 88 | expected int 89 | }{ 90 | {nil, 2}, 91 | {regexp.MustCompile(`\A~internal~ecs~pause\z`), 1}, 92 | {regexp.MustCompile(``), 0}, 93 | } 94 | 95 | ts := newServer() 96 | defer ts.Close() 97 | 98 | metadata = "testdata/metadata_ec2_awsvpc.json" 99 | stats = "testdata/stats_ec2_awsvpc.json" 100 | 101 | for _, tc := range tests { 102 | ctx := context.Background() 103 | 104 | c, err := NewClient(ts.URL, tc.ignoreContainer) 105 | if err != nil { 106 | t.Errorf("should not raise error: %v", err) 107 | } 108 | 109 | meta, err := c.GetTaskMetadata(ctx) 110 | if err != nil { 111 | t.Errorf("GetTaskMetadata() should not raise error: %v", err) 112 | } 113 | got := len(meta.Containers) 114 | if got != tc.expected { 115 | t.Errorf("meta.Containers expected %d containers, got %v containers", tc.expected, got) 116 | } 117 | 118 | stats, err := c.GetTaskStats(ctx) 119 | if err != nil { 120 | t.Errorf("GetStats() should not raise error: %v", err) 121 | } 122 | got = len(stats) 123 | if got != tc.expected { 124 | t.Errorf("GetStats() expected %d containers, got %v containers", tc.expected, got) 125 | } 126 | } 127 | 128 | } 129 | 130 | func TestErrorMessage(t *testing.T) { 131 | var body string 132 | 133 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 134 | w.WriteHeader(http.StatusBadRequest) 135 | if _, err := w.Write([]byte(body)); err != nil { 136 | t.Fatal(err) 137 | } 138 | }) 139 | ts := httptest.NewServer(handler) 140 | 141 | c, err := NewClient(ts.URL, nil) 142 | if err != nil { 143 | t.Errorf("should not raise error: %v", err) 144 | } 145 | 146 | tests := []struct { 147 | body string 148 | }{ 149 | {"Bad Request"}, 150 | {"Bad\nRequest"}, 151 | } 152 | 153 | for _, tc := range tests { 154 | body = tc.body 155 | 156 | ctx := context.Background() 157 | 158 | _, err = c.GetTaskMetadata(ctx) 159 | if err == nil { 160 | t.Errorf("should raise error") 161 | } 162 | 163 | u, _ := url.Parse(ts.URL) 164 | u.Path = path.Join(u.Path, metadataPath) 165 | expected := fmt.Sprintf("got status code %d (url: %s, body: %q)", http.StatusBadRequest, u, tc.body) 166 | 167 | got := err.Error() 168 | if got != expected { 169 | t.Errorf("error message expected %q, got %q", expected, got) 170 | } 171 | } 172 | } 173 | 174 | func TestNoProxy(t *testing.T) { 175 | var useProxy bool 176 | 177 | dt := http.DefaultTransport.(*http.Transport) 178 | origProxy := dt.Proxy 179 | defer func() { 180 | dt.Proxy = origProxy 181 | }() 182 | dt.Proxy = func(req *http.Request) (*url.URL, error) { 183 | useProxy = true 184 | return nil, nil 185 | } 186 | 187 | th := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) 188 | ts := httptest.NewServer(th) 189 | 190 | c, _ := NewClient(ts.URL, nil) 191 | _, err := c.GetTaskMetadata(context.Background()) 192 | if err != nil && !errors.Is(err, io.EOF) { 193 | t.Fatal(err) 194 | } 195 | 196 | if useProxy == true { 197 | t.Error("proxy should not be used") 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /probe/http_test.go: -------------------------------------------------------------------------------- 1 | package probe 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "net/url" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/mackerelio/mackerel-container-agent/config" 13 | ) 14 | 15 | func init() { 16 | defaultTimeoutHTTP = 100 * time.Millisecond 17 | } 18 | 19 | func TestProbeHTTP_Check(t *testing.T) { 20 | testCases := []struct { 21 | name string 22 | method string 23 | path string 24 | headers []config.Header 25 | timeoutSeconds int 26 | status int 27 | sleep time.Duration 28 | shouldErr bool 29 | useProxy bool 30 | }{ 31 | { 32 | name: "ok", 33 | path: "/healthy", 34 | status: http.StatusOK, 35 | }, 36 | { 37 | name: "3xx", 38 | path: "/healthy", 39 | status: http.StatusNotModified, 40 | }, 41 | { 42 | name: "4xx", 43 | path: "/healthy", 44 | status: http.StatusBadRequest, 45 | shouldErr: true, 46 | }, 47 | { 48 | name: "5xx", 49 | path: "/healthy", 50 | status: http.StatusServiceUnavailable, 51 | shouldErr: true, 52 | }, 53 | { 54 | name: "5xx", 55 | method: "PUT", 56 | path: "/healthy", 57 | status: http.StatusOK, 58 | }, 59 | { 60 | name: "timeout", 61 | path: "/healthy", 62 | status: http.StatusOK, 63 | sleep: 300 * time.Millisecond, 64 | shouldErr: true, 65 | }, 66 | { 67 | name: "timeout seconds", 68 | path: "/healthy", 69 | timeoutSeconds: 1, 70 | status: http.StatusOK, 71 | sleep: 300 * time.Millisecond, 72 | }, 73 | { 74 | name: "headers", 75 | path: "/healthy", 76 | headers: []config.Header{{Name: "X-Custom-Header", Value: "test"}}, 77 | status: http.StatusOK, 78 | }, 79 | { 80 | name: "host", 81 | path: "/healthy", 82 | headers: []config.Header{{Name: "Host", Value: "example.com"}}, 83 | status: http.StatusOK, 84 | }, 85 | { 86 | name: "proxy", 87 | path: "/", 88 | status: http.StatusOK, 89 | useProxy: true, 90 | }, 91 | } 92 | 93 | var proxyHandler func(*http.Request) (*url.URL, error) 94 | 95 | dt := http.DefaultTransport.(*http.Transport) 96 | origProxy := dt.Proxy 97 | defer func() { 98 | dt.Proxy = origProxy 99 | }() 100 | dt.Proxy = func(req *http.Request) (*url.URL, error) { 101 | return proxyHandler(req) 102 | } 103 | 104 | for _, tc := range testCases { 105 | t.Run(tc.name, func(t *testing.T) { 106 | ts := newHTTPServer(t, "ok", tc.headers, tc.method, tc.path, tc.sleep, tc.status) 107 | u, _ := url.Parse(ts.URL) 108 | 109 | var passedProxy bool 110 | proxyHandler = func(req *http.Request) (*url.URL, error) { 111 | passedProxy = true 112 | return nil, nil 113 | } 114 | 115 | var proxyURL *url.URL 116 | if tc.useProxy { 117 | ps := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 118 | passedProxy = true 119 | ts.Config.Handler.ServeHTTP(w, r) 120 | })) 121 | proxyURL, _ = url.Parse(ps.URL) 122 | defer ps.Close() 123 | } 124 | 125 | p := NewProbe(&config.Probe{ 126 | HTTP: &config.ProbeHTTP{ 127 | Scheme: u.Scheme, 128 | Host: u.Hostname(), 129 | Port: u.Port(), 130 | Method: tc.method, 131 | Path: tc.path, 132 | Headers: tc.headers, 133 | Proxy: config.URLWrapper{URL: proxyURL}, 134 | }, 135 | TimeoutSeconds: tc.timeoutSeconds, 136 | }) 137 | 138 | err := p.Check(context.Background()) 139 | 140 | if tc.useProxy && !passedProxy { 141 | t.Errorf("request should through the proxy") 142 | } 143 | if !tc.useProxy && passedProxy { 144 | t.Errorf("request should not through the proxy") 145 | } 146 | 147 | if err != nil && !tc.shouldErr { 148 | t.Errorf("should not raise error: %v", err) 149 | } 150 | if err == nil && tc.shouldErr { 151 | t.Errorf("should raise error: %v", err) 152 | } 153 | }) 154 | } 155 | } 156 | 157 | func newHTTPServer(t testing.TB, content string, headers []config.Header, method, path string, sleep time.Duration, status int) *httptest.Server { 158 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 159 | if method != "" && r.Method != method { 160 | t.Errorf("method should be %s but got %s", method, r.Method) 161 | } 162 | if r.URL.Path != path { 163 | t.Errorf("path should be %s but got %s", path, r.URL.Path) 164 | } 165 | for _, h := range headers { 166 | if strings.ToLower(h.Name) == "host" { 167 | if expected := h.Value; r.Host != expected { 168 | t.Errorf("host should be %s but got %s", expected, r.Host) 169 | } 170 | continue 171 | } 172 | if expected := h.Value; r.Header.Get(h.Name) != expected { 173 | t.Errorf("header %s should set %s but got %s", h.Name, expected, r.Header.Get(h.Name)) 174 | } 175 | } 176 | time.Sleep(sleep) 177 | w.WriteHeader(status) 178 | // Below writing a content sometimes expects returning an error. 179 | // A HTTP client may disconnect from the server during above time.Sleep. 180 | w.Write([]byte(content)) // nolint 181 | }) 182 | ts := httptest.NewServer(handler) 183 | t.Cleanup(ts.Close) 184 | return ts 185 | } 186 | -------------------------------------------------------------------------------- /platform/ecs/ecs.go: -------------------------------------------------------------------------------- 1 | package ecs 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "regexp" 8 | "time" 9 | 10 | ecsTypes "github.com/mackerelio/mackerel-container-agent/internal/amazon-ecs-agent/agent/handlers/v2" 11 | 12 | "github.com/mackerelio/golib/logging" 13 | 14 | "github.com/mackerelio/mackerel-container-agent/metric" 15 | "github.com/mackerelio/mackerel-container-agent/metric/hostinfo" 16 | "github.com/mackerelio/mackerel-container-agent/platform" 17 | "github.com/mackerelio/mackerel-container-agent/platform/ecs/taskmetadata" 18 | "github.com/mackerelio/mackerel-container-agent/spec" 19 | ) 20 | 21 | const ( 22 | executionEnvFargate = "AWS_ECS_FARGATE" 23 | executionEnvEC2 = "AWS_ECS_EC2" 24 | executionEnvManaged = "AWS_ECS_MANAGED_INSTANCES" 25 | 26 | // ExecutionEnvECSExternal : (experimental) It is a definition that is handled internally, not an environment variable. 27 | ExecutionEnvECSExternal = "ECS_EXTERNAL" 28 | ) 29 | 30 | var ( 31 | logger = logging.GetLogger("ecs") 32 | taskMetadataWaitForReadyInterval = 3 * time.Second 33 | ) 34 | 35 | // TaskMetadataEndpointClient interface gets task metadata and task stats 36 | type TaskMetadataEndpointClient interface { 37 | TaskMetadataGetter 38 | TaskStatsGetter 39 | } 40 | 41 | type networkMode string 42 | 43 | const ( 44 | bridgeNetworkMode networkMode = "bridge" 45 | hostNetworkMode networkMode = "host" 46 | awsvpcNetworkMode networkMode = "awsvpc" 47 | ) 48 | 49 | type ecsPlatform struct { 50 | client TaskMetadataEndpointClient 51 | provider provider 52 | networkMode networkMode 53 | } 54 | 55 | // NewECSPlatform creates a new Platform 56 | func NewECSPlatform(ctx context.Context, metadataURI string, executionEnv string, ignoreContainer *regexp.Regexp) (platform.Platform, error) { 57 | c, err := taskmetadata.NewClient(metadataURI, ignoreContainer) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | p, err := resolveProvider(executionEnv) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | meta, err := getTaskMetadata(ctx, c, taskMetadataWaitForReadyInterval) 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | nm, err := detectNetworkMode(meta) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | return &ecsPlatform{ 78 | client: c, 79 | provider: p, 80 | networkMode: nm, 81 | }, nil 82 | } 83 | 84 | // GetMetricGenerators gets metric generators 85 | func (p *ecsPlatform) GetMetricGenerators() []metric.Generator { 86 | g := []metric.Generator{ 87 | newMetricGenerator(p.client, hostinfo.NewGenerator()), 88 | } 89 | 90 | if p.networkMode != bridgeNetworkMode { 91 | g = append(g, metric.NewInterfaceGenerator()) 92 | } 93 | 94 | return g 95 | } 96 | 97 | // GetSpecGenerators gets spec generator 98 | func (p *ecsPlatform) GetSpecGenerators() []spec.Generator { 99 | return []spec.Generator{ 100 | newSpecGenerator(p.client, p.provider), 101 | &spec.CPUGenerator{}, 102 | } 103 | } 104 | 105 | // GetCustomIdentifier gets custom identifier 106 | func (p *ecsPlatform) GetCustomIdentifier(context.Context) (string, error) { 107 | return "", nil 108 | } 109 | 110 | // StatusRunning reports p status is running 111 | func (p *ecsPlatform) StatusRunning(ctx context.Context) bool { 112 | meta, err := p.client.GetTaskMetadata(ctx) 113 | if err != nil { 114 | logger.Warningf("failed to get metadata: %s", err) 115 | return false 116 | } 117 | return isRunning(meta.KnownStatus) 118 | } 119 | 120 | func isRunning(status string) bool { 121 | return status == "RUNNING" 122 | } 123 | 124 | func resolveProvider(executionEnv string) (provider, error) { 125 | switch executionEnv { 126 | case executionEnvFargate: 127 | return fargateProvider, nil 128 | case executionEnvEC2: 129 | return ecsProvider, nil 130 | case executionEnvManaged: 131 | return ecsManagedProvider, nil 132 | case ExecutionEnvECSExternal: 133 | return ecsAnywhereProvider, nil 134 | default: 135 | return provider("UNKNOWN"), fmt.Errorf("unknown execution env: %q", executionEnv) 136 | } 137 | } 138 | 139 | func detectNetworkMode(meta *ecsTypes.TaskResponse) (networkMode, error) { 140 | if len(meta.Containers) == 0 { 141 | return "", errors.New("there are no containers") 142 | } 143 | 144 | if len(meta.Containers[0].Networks) == 0 { 145 | return "", errors.New("there are no networks") 146 | } 147 | 148 | nm := meta.Containers[0].Networks[0].NetworkMode 149 | switch nm { 150 | case "default", "bridge": 151 | return bridgeNetworkMode, nil 152 | case "host": 153 | return hostNetworkMode, nil 154 | case "awsvpc": 155 | return awsvpcNetworkMode, nil 156 | default: 157 | return "", fmt.Errorf("unsupported NetworkMode: %v", nm) 158 | } 159 | } 160 | 161 | func getTaskMetadata(ctx context.Context, client TaskMetadataGetter, interval time.Duration) (*ecsTypes.TaskResponse, error) { 162 | // GetTaskMetadata will return an error until all containers associated with the task have been created. 163 | // To avoid exiting with this error, retry until GetTaskMetadata succeeds. 164 | for { 165 | meta, err := client.GetTaskMetadata(ctx) 166 | if err == nil { 167 | return meta, nil 168 | } 169 | 170 | logger.Infof("wait for the task API to be ready: %q", err) 171 | 172 | select { 173 | case <-time.After(interval): 174 | continue 175 | case <-ctx.Done(): 176 | return nil, ctx.Err() 177 | } 178 | } 179 | } 180 | --------------------------------------------------------------------------------