├── .dockerignore
├── .editorconfig
├── .github
├── dependabot.yml
└── workflows
│ ├── build.yml
│ ├── codeql.yml
│ ├── lint.yml
│ ├── release.yml
│ ├── scorecard.yml
│ └── test.yml
├── .gitignore
├── .golangci.yml
├── .releaserc
├── Dockerfile
├── LICENSE
├── Makefile
├── Procfile
├── README.md
├── SECURITY.md
├── agent
├── agent.go
└── controllers.go
├── api
├── agent.go
├── application.go
├── check.go
├── component.go
├── config.go
├── event.go
├── evidence.go
├── global.go
├── incident.go
├── logs.go
├── notifications.go
├── playbook.go
├── responders.go
├── team.go
├── upstream.go
├── v1
│ ├── application.go
│ ├── connection_types.go
│ ├── groupversion_info.go
│ ├── incident_rule_types.go
│ ├── notification_silence_types.go
│ ├── notification_types.go
│ ├── permission_group_types.go
│ ├── permission_types.go
│ ├── playbook_actions.go
│ ├── playbook_actions_test.go
│ ├── playbook_types.go
│ └── zz_generated.deepcopy.go
└── zz_generated.deepcopy.go
├── application
├── application.go
├── controller.go
├── job.go
├── job_test.go
├── scraper.go
├── suite_test.go
└── testdata
│ ├── azure-scrapeconfig.yaml
│ └── azure.yaml
├── artifacts
├── artifacts.go
├── controllers.go
└── jobs.go
├── auth
├── admin_user.go
├── basic.go
├── clerk_client.go
├── controllers.go
├── kratos.go
├── kratos_client.go
├── middleware.go
├── mock.go
├── templates.go
└── tokens.go
├── catalog
└── controllers.go
├── cmd
├── catalog.go
├── offline.go
├── playbook.go
├── root.go
├── server.go
├── sync.go
└── token.go
├── components
└── logs.go
├── config
├── crds
│ ├── mission-control.flanksource.com_applications.yaml
│ ├── mission-control.flanksource.com_connections.yaml
│ ├── mission-control.flanksource.com_incidentrules.yaml
│ ├── mission-control.flanksource.com_notifications.yaml
│ ├── mission-control.flanksource.com_notificationsilences.yaml
│ ├── mission-control.flanksource.com_permissiongroups.yaml
│ ├── mission-control.flanksource.com_permissions.yaml
│ └── mission-control.flanksource.com_playbooks.yaml
└── schemas
│ ├── application.schema.json
│ ├── connection.schema.json
│ ├── incident-rules.schema.json
│ ├── notification.schema.json
│ ├── notificationsilence.schema.json
│ ├── openapi.go
│ ├── permission.schema.json
│ ├── permissiongroup.schema.json
│ ├── playbook-spec.schema.json
│ └── playbook.schema.json
├── connection
├── check.go
└── controllers.go
├── db
├── agents.go
├── applications.go
├── applications_test.go
├── artifacts.go
├── canaries.go
├── components.go
├── config.go
├── connections.go
├── evidences.go
├── incidents.go
├── middleware.go
├── middleware_test.go
├── models
│ ├── incident.go
│ └── team.go
├── notifications.go
├── notifications_test.go
├── people.go
├── permissions.go
├── playbooks.go
├── properties.go
└── suite_test.go
├── deploy
├── apm-hub
│ └── kustomization.yaml
├── canary-checker
│ ├── kustomization.yaml
│ ├── patch.yaml
│ └── postgres-secret.yaml
├── config-db
│ ├── kustomization.yaml
│ ├── patch.yaml
│ └── postgres-secret.yaml
├── incident-commander
│ ├── kustomization.yaml
│ ├── manager.yaml
│ └── postgres-secret.yaml
├── kustomization.yaml
├── namespace.yaml
└── postgres
│ ├── kustomization.yaml
│ ├── postgres-init-script.yaml
│ └── postgres.yaml
├── development.md
├── echo
├── kube_config_download.go
├── logger.go
├── people.go
├── search.go
└── serve.go
├── events
├── event_queue.go
└── ring.go
├── fixtures
├── applications
│ └── sap-erp.yaml
├── connections
│ ├── awskms.yaml
│ └── gcpkms.yaml
├── notifications
│ ├── check-label-match-query.yaml
│ ├── component-match-query.yaml
│ ├── component-status.yaml
│ ├── config-health-match-query.yaml
│ ├── config-health.yaml
│ ├── deployment-with-inhibition.yaml
│ ├── health-check.yaml
│ ├── health-playbook.yaml
│ ├── incidents.yaml
│ ├── kube-cronjob-failing.yaml
│ ├── kube-deployment-unhealthy.yaml
│ └── kube-pod-crashlooping.yaml
├── permissions
│ ├── agent-based-permission.yaml
│ ├── allow-person-playbook.yaml
│ ├── config-notification-group-playbook-permission.yaml
│ ├── connection-read.yaml
│ ├── deny-person-playbook.yaml
│ ├── notification-playbook-permission.yaml
│ ├── playbook-connection.yaml
│ ├── rbac-catalog-read.yaml
│ ├── scraper-connection.yaml
│ ├── system.yaml
│ ├── tag-based-permission.yaml
│ └── topology-connection.yaml
├── playbooks
│ ├── action-result.yaml
│ ├── ai-diagnose-aws-ollama.yaml
│ ├── ai-diagnose-kubernetes-resource.yaml
│ ├── ai-diagnose-slack-notification.yaml
│ ├── ai-recommend-playbook.yaml
│ ├── ai-with-context-from-playbook.yaml
│ ├── azure-devops.yaml
│ ├── catalog_traversal.yaml
│ ├── conditions-fail.yaml
│ ├── connection-from-scraper.yaml
│ ├── delayed-exec.yaml
│ ├── delayed-option.yaml
│ ├── delete-pv.yaml
│ ├── deleting-configmap.yaml
│ ├── ec2.yaml
│ ├── env-secrets.yaml
│ ├── exec-artifact.yaml
│ ├── exec-checkout.yaml
│ ├── exec-default-parameter.yaml
│ ├── exec-delayed-role-binding.yaml
│ ├── exec-filter.yaml
│ ├── exec-kubectl-logs-artifacts.yaml
│ ├── exec-powershell.yaml
│ ├── github.yaml
│ ├── gitops-role-binding.yaml
│ ├── gitops.yaml
│ ├── http-secret-parameter.yaml
│ ├── http.yaml
│ ├── logs
│ │ ├── cloudwatch.yaml
│ │ ├── k8s.yaml
│ │ ├── loki.yaml
│ │ └── opensearch.yaml
│ ├── notification-playbook.yaml
│ ├── notify-database-component-fail.yaml
│ ├── params.yaml
│ ├── pod.yaml
│ ├── postgres-backup.yaml
│ ├── retry.yaml
│ ├── runner.yaml
│ ├── runs-on-simple.yaml
│ ├── scale-deployment.yaml
│ ├── sql.yaml
│ ├── stop-component-ec2-instances.yaml
│ ├── stop-crashloop-pods.yaml
│ ├── upgrade-eks-cluster.yaml
│ └── webhook-trigger.yaml
├── rules
│ └── default.yaml
└── silences
│ ├── postgresql-sts.yaml
│ ├── rds.yaml
│ ├── silence-test-deployments.yaml
│ └── silence-test-env.yaml
├── go.mod
├── go.sum
├── hack
└── generate-schemas
│ ├── .gitignore
│ └── main.go
├── incidents
├── evidence_done_test.go
├── evidence_script_cel.go
├── evidence_script_cel_test.go
├── incident_rules_test.go
├── jobs.go
├── responder
│ ├── comments_sync.go
│ ├── config_sync.go
│ ├── jira
│ │ ├── config.go
│ │ ├── jira.go
│ │ └── notify.go
│ ├── msplanner
│ │ ├── config.go
│ │ ├── msplanner.go
│ │ └── notify.go
│ ├── responder_events.go
│ └── responders.go
├── rules.go
└── suite_test.go
├── jobs
├── catalog.go
├── event_queue.go
├── job_history_cleanup.go
├── jobs.go
├── notification_history_cleanup.go
├── playbook.go
├── team_components.go
└── upstream.go
├── k8s
├── client.go
└── kommons.go
├── llm
├── cost.go
├── cost_test.go
├── gemini.go
├── llm.go
├── tools
│ ├── config.go
│ ├── extract_details.go
│ └── recommend_playbook.go
└── types.go
├── logs
├── cloudwatch
│ ├── search.go
│ ├── types.go
│ └── zz_generated.deepcopy.go
├── config.go
├── handler.go
├── k8s
│ ├── logs.go
│ └── zz_generated.deepcopy.go
├── logs.go
├── loki
│ ├── loki.go
│ ├── types.go
│ └── zz_generated.deepcopy.go
├── mapping.go
├── mapping_test.go
├── opensearch
│ ├── search.go
│ ├── types.go
│ └── zz_generated.deepcopy.go
└── zz_generated.deepcopy.go
├── mail
└── mailer.go
├── main.go
├── notification
├── cel.go
├── cel_test.go
├── context.go
├── controllers.go
├── events.go
├── events_test.go
├── job.go
├── metrics.go
├── notification.go
├── notification_test.go
├── ratelimit.go
├── send.go
├── shoutrrr.go
├── shoutrrr_test.go
├── silence.go
├── silence_test.go
├── slack.go
├── suite_test.go
├── template.go
└── templates
│ ├── check.failed
│ ├── check.passed
│ ├── component.health
│ ├── config.db.update
│ └── config.health
├── pkg
└── clients
│ ├── aws
│ └── aws.go
│ └── git
│ ├── connectors
│ ├── connectors.go
│ ├── connectors_test.go
│ ├── git_access_token.go
│ └── git_ssh.go
│ └── git.go
├── playbook
├── actions
│ ├── actions.go
│ ├── actions_test.go
│ ├── ai.go
│ ├── ai_slack.go
│ ├── azure_devops_pipeline.go
│ ├── exec.go
│ ├── github.go
│ ├── gitops.go
│ ├── gitops_test.go
│ ├── http.go
│ ├── interpreter.go
│ ├── logs.go
│ ├── logs_test.go
│ ├── notification.go
│ ├── pod.go
│ ├── sql.go
│ ├── suite_test.go
│ └── testdata
│ │ └── dummy-repo
│ │ └── notification.yaml
├── approval.go
├── controllers.go
├── events.go
├── events_test.go
├── jobs.go
├── params.go
├── pg_listeners.go
├── playbook.go
├── playbook_test.go
├── run_consumer.go
├── runner
│ ├── agent.go
│ ├── cel.go
│ ├── exec.go
│ ├── functions.go
│ ├── gitops.go
│ ├── longpoll.go
│ ├── runner.go
│ └── template.go
├── sdk
│ └── client.go
├── suite_test.go
├── test.properties
├── testdata
│ ├── action-ai.yaml
│ ├── action-approvals.yaml
│ ├── action-check.yaml
│ ├── action-component.yaml
│ ├── action-exec-artifacts.yaml
│ ├── action-filter.yaml
│ ├── action-http-authorized.yaml
│ ├── action-http-unauthorized.yaml
│ ├── action-last-result.yaml
│ ├── action-params.yaml
│ ├── agent-runner.yaml
│ ├── bad-action-spec.yaml
│ ├── bad-spec.yaml
│ ├── connections
│ │ ├── artifact.yaml
│ │ ├── gemini.yaml
│ │ └── httpbin.yaml
│ ├── e2e
│ │ ├── loki.yaml
│ │ └── opensearch.yaml
│ ├── echo.yaml
│ ├── exec-connection-kubernetes.yaml
│ ├── exec-powershell.yaml
│ ├── my-kube-config.yaml
│ ├── permissions
│ │ ├── allow-ai-artifacts-connection.yaml
│ │ ├── allow-ai-gemini-connection.yaml
│ │ ├── allow-config-read.yaml
│ │ ├── allow-exec-playbook-artifact.yaml
│ │ ├── allow-john-connection.yaml
│ │ ├── allow-loki-logs-artifact.yaml
│ │ ├── allow-opensearch-logs-artifact.yaml
│ │ ├── allow-playbook-connection.yaml
│ │ ├── allow-playbook.yaml
│ │ ├── allow-retry-playbook.yaml
│ │ ├── deny-echo-playbook.yaml
│ │ └── deny-playbook-on-config.yaml
│ ├── retries.yaml
│ └── seed.go
├── webhook.go
└── webhook_test.go
├── push
├── push_test.go
├── suite_test.go
└── topology.go
├── rbac
├── adapter
│ └── permission.go
├── controllers.go
├── middleware.go
└── rbac_test.go
├── snapshot
├── controllers.go
├── dump.go
├── snapshot.go
└── writer.go
├── teams
└── teams.go
├── telemetry
└── tracer.go
├── tests
├── e2e
│ ├── playbooks_test.go
│ ├── setup
│ │ ├── docker-compose.yaml
│ │ ├── seed-loki.json
│ │ └── seed-opensearch.json
│ └── suite_test.go
├── fixtures_schema_validate_test.go
└── middleware_test.go
├── upstream
├── controllers.go
├── suite_test.go
└── upstream_test.go
├── utils
├── bytes.go
├── bytes_test.go
├── dir.go
├── error.go
├── http.go
├── map.go
└── parse.go
└── vars
└── vars.go
/.dockerignore:
--------------------------------------------------------------------------------
1 | Dockerfile
2 | .bin/
3 | .vscode/
4 | .idea/
5 | .env
6 | .DS_Store
7 | cover.out
8 | test.test
9 | build/
10 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | insert_final_newline = true
6 | trim_trailing_whitespace = true
7 | charset = utf-8
8 |
9 | [*.{yml,yaml,md}]
10 | indent_style = space
11 | indent_size = 2
12 | quote_type = double
13 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "gomod"
4 | directory: "/"
5 | schedule:
6 | interval: "daily"
7 |
8 | - package-ecosystem: "gomod"
9 | directory: "/hack/generate-schemas"
10 | schedule:
11 | interval: "daily"
12 |
13 | - package-ecosystem: github-actions
14 | directory: /
15 | schedule:
16 | interval: monthly
17 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | on: pull_request
2 | name: Build
3 | permissions: read-all
4 | jobs:
5 | build:
6 | runs-on: ubuntu-latest
7 | steps:
8 | - name: Checkout code
9 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
10 | - name: Build Container
11 | run: make docker
12 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Lint
2 | on:
3 | pull_request:
4 | permissions: read-all
5 | jobs:
6 | golangci:
7 | name: lint
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Clear up disk space
11 | run: |
12 | rm -rf /usr/share/dotnet
13 | rm -rf /opt/ghc
14 | rm -rf /usr/local/share/boost
15 | rm -rf $AGENT_TOOLSDIRECTORY
16 | rm -rf /opt/hostedtoolcache
17 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
18 | - name: Install Go
19 | uses: buildjet/setup-go@v5
20 | with:
21 | go-version: 1.23.x
22 | - run: make -B manifests
23 | - name: golangci-lint
24 | uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
25 | with:
26 | version: latest
27 | args: --verbose --max-same-issues=0 --max-issues-per-linter=0
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .bin/
2 | .kube
3 | .vscode/
4 | .idea/
5 | .git-clones/
6 | .env
7 | .DS_Store
8 | cover.out
9 | test.test
10 | build/
11 | scripts/
12 | Chart.lock
13 | charts/.devenv*
14 | devenv.local.nix
15 | ginkgo.report
16 | mission-control.properties
17 | test.properties
18 | cover.out
19 | coverprofile.out
20 | *.test
21 | junit-report.xml
22 | nohup.out
23 | .envrc
24 | .creds
25 | msg.tpl
26 | .windsurfrules
27 | test-reports
28 | .cursor/
29 | .artifacts/
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | version: "2"
2 | linters:
3 | settings:
4 | govet:
5 | disable:
6 | - printf
7 | exclusions:
8 | generated: lax
9 | presets:
10 | - comments
11 | - common-false-positives
12 | - legacy
13 | - std-error-handling
14 | paths:
15 | - third_party$
16 | - builtin$
17 | - examples$
18 | rules:
19 | - linters:
20 | - staticcheck
21 | text: 'QF1008:'
22 | formatters:
23 | exclusions:
24 | generated: lax
25 | paths:
26 | - third_party$
27 | - builtin$
28 | - examples$
29 |
--------------------------------------------------------------------------------
/.releaserc:
--------------------------------------------------------------------------------
1 | branches:
2 | - name: main
3 | plugins:
4 | - - "@semantic-release/commit-analyzer"
5 | - releaseRules:
6 | - { type: doc, scope: README, release: patch }
7 | - { type: fix, release: patch }
8 | - { type: chore, release: patch }
9 | - { type: refactor, release: patch }
10 | - { type: feat, release: patch }
11 | - { type: ci, release: patch }
12 | - { type: style, release: patch }
13 | parserOpts:
14 | noteKeywords:
15 | - MAJOR RELEASE
16 | - "@semantic-release/release-notes-generator"
17 | - - "@semantic-release/github"
18 | - assets:
19 | - path: ./.bin/incident-commander-amd64
20 | name: incident-commander-amd64
21 | - path: ./.bin/incident-commander.exe
22 | name: incident-commander.exe
23 | - path: ./.bin/incident-commander_osx-amd64
24 | name: incident-commander_osx-amd64
25 | - path: ./.bin/incident-commander_osx-arm64
26 | name: incident-commander_osx-arm64
27 | # - path: ./.bin/release.yaml
28 | # name: release.yaml
29 | # From: https://github.com/semantic-release/github/pull/487#issuecomment-1486298997
30 | successComment: false
31 | failTitle: false
32 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.24-bookworm@sha256:89a04cc2e2fbafef82d4a45523d4d4ae4ecaf11a197689036df35fef3bde444a AS builder
2 | WORKDIR /app
3 |
4 | ARG VERSION
5 | COPY go.mod /app/go.mod
6 | COPY go.sum /app/go.sum
7 | RUN go mod download
8 | COPY ./ ./
9 | RUN make build
10 |
11 | FROM flanksource/base-image:0.5.15@sha256:8d3fe5816e10e0eb0e74ef30dbbc66d54402dcbdab80b72c7461811a05825dbc
12 | WORKDIR /app
13 |
14 | ENV DEBIAN_FRONTEND=noninteractive
15 | RUN apt-get update && \
16 | apt-get install -y python3 python3-pip --no-install-recommends && \
17 | rm -Rf /var/lib/apt/lists/* && \
18 | rm -Rf /usr/share/doc && rm -Rf /usr/share/man && \
19 | apt-get clean
20 |
21 | RUN arkade get --path /usr/bin eksctl flux helm kustomize terraform && \
22 | chmod +x /usr/bin/eksctl /usr/bin/flux /usr/bin/helm /usr/bin/kustomize /usr/bin/terraform
23 |
24 | COPY --from=builder /app/.bin/incident-commander /app
25 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
26 |
27 | RUN /app/incident-commander go-offline
28 | ENTRYPOINT ["/app/incident-commander"]
29 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | # canarywatch: sh -c 'cd ../canary-checker && watchexec -e go -c make fast-build install'
2 | # configwatch: sh -c 'cd ../confighub && watchexec -e go -c make build install'
3 | # apmwatch: sh -c 'cd ../apm-hub && watchexec -e go -c make build install'
4 | # incidentwatch: sh -c 'watchexec -e go -c make build install'
5 | canary: canary-checker serve --httpPort 8081 --db "postgres://localhost/incident_commander" -v --disable-postgrest
6 | config: config-db serve --httpPort 8085 --db "postgres://localhost/incident_commander" -v --disable-postgrest
7 | ui: sh -c 'cd ../flanksource-ui && NEXT_PUBLIC_WITHOUT_SESSION=true npm run dev'
8 | apm: apm-hub serve --httpPort 8082 ../apm-hub/samples/config.yaml
9 | incident: incident-commander serve --apm-hub http://localhost:8082 --canary-checker http://localhost:8081 --db "postgres://localhost/incident_commander" --config-db http://localhost:8085
10 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Reporting a Vulnerability
4 |
5 | If you discover any security vulnerabilities within this project, please report them to our team immediately. We appreciate your help in making this project more secure for everyone.
6 |
7 | To report a vulnerability, please follow these steps:
8 |
9 | 1. **Email**: Send an email to our security team at [security@flanksource.com](mailto:security@flanksource.com) with a detailed description of the vulnerability.
10 | 2. **Subject Line**: Use the subject line "Security Vulnerability Report" to ensure prompt attention.
11 | 3. **Information**: Provide as much information as possible about the vulnerability, including steps to reproduce it and any supporting documentation or code snippets.
12 | 4. **Confidentiality**: We prioritize the confidentiality of vulnerability reports. Please avoid publicly disclosing the issue until we have had an opportunity to address it.
13 |
14 | Our team will respond to your report as soon as possible and work towards a solution. We appreciate your responsible disclosure and cooperation in maintaining the security of this project.
15 |
16 | Thank you for your contribution to the security of this project!
17 |
18 | **Note:** This project follows responsible disclosure practices.
19 |
--------------------------------------------------------------------------------
/agent/agent.go:
--------------------------------------------------------------------------------
1 | package agent
2 |
3 | import (
4 | "fmt"
5 | "time"
6 |
7 | "github.com/flanksource/commons/rand"
8 | "github.com/flanksource/duty/context"
9 | "github.com/flanksource/duty/rbac"
10 | "github.com/samber/lo"
11 |
12 | "github.com/flanksource/incident-commander/api"
13 | "github.com/flanksource/incident-commander/db"
14 | )
15 |
16 | // generateAgent creates a new person and a new agent and associates them.
17 | func generateAgent(ctx context.Context, body api.GenerateAgentRequest) (*api.GeneratedAgent, error) {
18 | username, password, err := genUsernamePassword()
19 | if err != nil {
20 | return nil, fmt.Errorf("failed to generate username and password: %w", err)
21 | }
22 |
23 | person, err := db.CreatePerson(ctx, username, fmt.Sprintf("%s@local", username), db.PersonTypeAgent)
24 | if err != nil {
25 | return nil, fmt.Errorf("failed to create a new person: %w", err)
26 | }
27 |
28 | token, err := db.CreateAccessToken(ctx, person.ID, "default", password, lo.ToPtr(time.Hour*24*365))
29 | if err != nil {
30 | return nil, fmt.Errorf("failed to create a new access token: %w", err)
31 | }
32 |
33 | if err := rbac.AddRoleForUser(person.ID.String(), "agent"); err != nil {
34 | return nil, fmt.Errorf("failed to add 'agent' role to the new person: %w", err)
35 | }
36 |
37 | if err := db.CreateAgent(ctx, body.Name, &person.ID, body.Properties); err != nil {
38 | return nil, fmt.Errorf("failed to create a new agent: %w", err)
39 | }
40 |
41 | return &api.GeneratedAgent{
42 | ID: person.ID.String(),
43 | Username: username,
44 | AccessToken: token,
45 | }, nil
46 | }
47 |
48 | // genUsernamePassword generates a random pair of username and password
49 | func genUsernamePassword() (username, password string, err error) {
50 | username, err = rand.GenerateRandHex(8)
51 | if err != nil {
52 | return "", "", err
53 | }
54 |
55 | password, err = rand.GenerateRandHex(32)
56 | if err != nil {
57 | return "", "", err
58 | }
59 |
60 | return fmt.Sprintf("agent-%s", username), password, nil
61 | }
62 |
--------------------------------------------------------------------------------
/agent/controllers.go:
--------------------------------------------------------------------------------
1 | package agent
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 |
7 | "github.com/flanksource/commons/logger"
8 | dutyAPI "github.com/flanksource/duty/api"
9 | "github.com/flanksource/duty/context"
10 | "github.com/flanksource/incident-commander/api"
11 | "github.com/labstack/echo/v4"
12 | )
13 |
14 | // GenerateAgent creates a new person and a new agent and associates them.
15 | func GenerateAgent(c echo.Context) error {
16 | ctx := c.Request().Context().(context.Context)
17 |
18 | var body api.GenerateAgentRequest
19 | if err := json.NewDecoder(c.Request().Body).Decode(&body); err != nil {
20 | return c.JSON(http.StatusBadRequest, dutyAPI.HTTPError{Err: err.Error()})
21 | }
22 |
23 | agent, err := generateAgent(ctx, body)
24 | if err != nil {
25 | logger.Errorf("failed to generate a new agent: %v", err)
26 | return c.JSON(http.StatusInternalServerError, dutyAPI.HTTPError{Err: err.Error(), Message: "error generating agent"})
27 | }
28 |
29 | return c.JSON(http.StatusCreated, agent)
30 | }
31 |
--------------------------------------------------------------------------------
/api/agent.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | type GenerateAgentRequest struct {
4 | Name string
5 | Properties map[string]string
6 | }
7 |
8 | type GeneratedAgent struct {
9 | ID string `json:"id"`
10 | Username string `json:"username"`
11 | AccessToken string `json:"access_token"`
12 | }
13 |
--------------------------------------------------------------------------------
/api/check.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "encoding/json"
5 | "time"
6 |
7 | "github.com/google/uuid"
8 | )
9 |
10 | type Check struct {
11 | ID uuid.UUID `json:"id"`
12 | LastTransitionTime time.Time `json:"last_transition_time"`
13 | LastRuntime time.Time `json:"last_runtime"`
14 | CreatedAt time.Time `json:"created_at"`
15 | Status string `json:"status"`
16 | }
17 |
18 | // AsMap returns a map[string]any representation of the Check object
19 | // with some additional fields.
20 | func (t Check) AsMap() map[string]any {
21 | m := make(map[string]any)
22 | b, _ := json.Marshal(t)
23 | _ = json.Unmarshal(b, &m)
24 |
25 | m["age"] = time.Since(t.LastTransitionTime)
26 | return m
27 | }
28 |
--------------------------------------------------------------------------------
/api/component.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "encoding/json"
5 | "time"
6 |
7 | "github.com/flanksource/duty/types"
8 | "github.com/google/uuid"
9 | )
10 |
11 | type Component struct {
12 | ID uuid.UUID `json:"id,omitempty" gorm:"default:generate_ulid()"` //nolint
13 | Name string `json:"name,omitempty"`
14 | Text string `json:"text,omitempty"`
15 | Schedule string `json:"schedule,omitempty"`
16 | TopologyType string `json:"topology_type,omitempty"`
17 | Namespace string `json:"namespace,omitempty"`
18 | Labels types.JSONStringMap `json:"labels,omitempty"`
19 | Owner string `json:"owner,omitempty"`
20 | Status string `json:"status,omitempty"`
21 | StatusReason string `json:"statusReason,omitempty"`
22 | Path string `json:"path,omitempty"`
23 | Order int `json:"order,omitempty" gorm:"-"`
24 | Type string `json:"type,omitempty"`
25 | Lifecycle string `json:"lifecycle,omitempty"`
26 | Properties types.JSON `json:"properties,omitempty"`
27 | CreatedAt time.Time `json:"created_at,omitempty" time_format:"postgres_timestamp"`
28 | UpdatedAt time.Time `json:"updated_at,omitempty" time_format:"postgres_timestamp"`
29 | DeletedAt *time.Time `json:"deleted_at,omitempty" time_format:"postgres_timestamp" swaggerignore:"true"`
30 | IsLeaf bool `json:"is_leaf"`
31 | CostPerMinute float64 `gorm:"column:cost_per_minute"`
32 | Cost1d float64 `gorm:"column:cost_total_1d"`
33 | Cost7d float64 `gorm:"column:cost_total_7d"`
34 | Cost30d float64 `gorm:"column:cost_total_30d"`
35 | }
36 |
37 | func (c Component) AsMap() map[string]any {
38 | m := make(map[string]any)
39 | b, _ := json.Marshal(&c)
40 | _ = json.Unmarshal(b, &m)
41 | return m
42 | }
43 |
--------------------------------------------------------------------------------
/api/config.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "encoding/json"
5 | "time"
6 |
7 | "github.com/flanksource/duty/types"
8 | "github.com/google/uuid"
9 | )
10 |
11 | type ConfigItem struct {
12 | ID uuid.UUID `json:"id"`
13 | Config types.JSONMap `json:"config"`
14 | }
15 |
16 | type ConfigAnalysis struct {
17 | ID uuid.UUID `json:"id"`
18 | Status string `json:"status"`
19 | LastObserved time.Time `json:"last_observed"`
20 | }
21 |
22 | func (t *ConfigAnalysis) TableName() string {
23 | return "config_analysis"
24 | }
25 |
26 | func (t ConfigAnalysis) AsMap() map[string]any {
27 | m := make(map[string]any)
28 | b, _ := json.Marshal(t)
29 | _ = json.Unmarshal(b, &m)
30 |
31 | m["age"] = time.Since(t.LastObserved)
32 | return m
33 | }
34 |
--------------------------------------------------------------------------------
/api/global.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "github.com/google/uuid"
5 | )
6 |
7 | var (
8 | BuildVersion string
9 |
10 | SystemUserID *uuid.UUID
11 | CanaryCheckerPath string
12 | ApmHubPath string
13 | ConfigDB string
14 | Namespace string
15 |
16 | // Full URL of the mission control web UI.
17 | FrontendURL string
18 |
19 | // Full URL of the mission contorl backend.
20 | PublicURL string
21 |
22 | // DefaultArtifactConnection is the connection that's used to save all playbook artifacts.
23 | DefaultArtifactConnection string
24 | )
25 |
26 | const (
27 | PropertyIncidentsDisabled = "incidents.disable"
28 | )
29 |
30 | type LLMBackend string
31 |
32 | const (
33 | LLMBackendAnthropic LLMBackend = "anthropic"
34 | LLMBackendOpenAI LLMBackend = "openai"
35 | LLMBackendOllama LLMBackend = "ollama"
36 | LLMBackendGemini LLMBackend = "gemini"
37 | )
38 |
--------------------------------------------------------------------------------
/api/logs.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/flanksource/duty/types"
7 | )
8 |
9 | type LogLine struct {
10 | Timestamp time.Time `json:"timestamp"`
11 | Message string `json:"message"`
12 | Labels types.JSONStringMap `json:"labels"`
13 | }
14 |
15 | type LogsResponse struct {
16 | Total int `json:"total"`
17 | Results []LogLine `json:"results"`
18 | }
19 |
20 | type ComponentLogs struct {
21 | Logs []LogLine `json:"logs"`
22 | ID string `json:"id"`
23 | Name string `json:"name"`
24 | Type string `json:"type"`
25 | }
26 |
--------------------------------------------------------------------------------
/api/notifications.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | gocontext "context"
5 | "database/sql/driver"
6 |
7 | "github.com/flanksource/duty/types"
8 | "gorm.io/gorm"
9 | "gorm.io/gorm/clause"
10 | "gorm.io/gorm/schema"
11 | )
12 |
13 | // SystemSMTP indicates that the shoutrrr URL for smtp should use
14 | // the system's SMTP credentials.
15 | const SystemSMTP = "smtp://system/"
16 |
17 | // +kubebuilder:object:generate=true
18 | type NotificationConfig struct {
19 | Name string `json:"name,omitempty"` // A unique name to identify this notification configuration.
20 | Filter string `json:"filter,omitempty"` // Filter is a CEL-expression used to decide whether this notification client should send the notification
21 | URL string `json:"url,omitempty"` // URL in the form of Shoutrrr notification service URL schema
22 | Connection string `json:"connection,omitempty"` // Connection is the name of the connection
23 | Properties map[string]string `json:"properties,omitempty" template:"true"` // Configuration properties for Shoutrrr. It's Templatable.
24 | }
25 |
26 | func (t NotificationConfig) Value() (driver.Value, error) {
27 | return types.GenericStructValue(t, true)
28 | }
29 |
30 | func (t *NotificationConfig) Scan(val any) error {
31 | return types.GenericStructScan(&t, val)
32 | }
33 |
34 | func (t NotificationConfig) GormDataType() string {
35 | return "notificationConfig"
36 | }
37 |
38 | func (t NotificationConfig) GormDBDataType(db *gorm.DB, field *schema.Field) string {
39 | return types.JSONGormDBDataType(db.Dialector.Name())
40 | }
41 |
42 | func (t NotificationConfig) GormValue(ctx gocontext.Context, db *gorm.DB) clause.Expr {
43 | return types.GormValue(t)
44 | }
45 |
--------------------------------------------------------------------------------
/api/playbook.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "github.com/flanksource/duty/types"
5 | "github.com/google/uuid"
6 | )
7 |
8 | // PlaybookListItem is the response struct for listing playbooks
9 | // for a filter/selector.
10 | type PlaybookListItem struct {
11 | ID uuid.UUID `json:"id"`
12 | Name string `json:"name"`
13 | Parameters types.JSON `json:"parameters,omitempty"`
14 | }
15 |
--------------------------------------------------------------------------------
/api/upstream.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/flanksource/duty/models"
7 | "github.com/flanksource/duty/upstream"
8 | )
9 |
10 | // List of tables that a mission-control UPSTREAM should allow reconciliation
11 | // from other agents.
12 | var AllowedReconciliationTables = []string{
13 | "topologies",
14 | "components",
15 | "config_scrapers",
16 | "config_items",
17 | "canaries",
18 | "checks",
19 | }
20 |
21 | var UpstreamConf upstream.UpstreamConfig
22 |
23 | type CanaryPullResponse struct {
24 | Before time.Time `json:"before"`
25 | Canaries []models.Canary `json:"canaries,omitempty"`
26 | }
27 |
--------------------------------------------------------------------------------
/api/v1/groupversion_info.go:
--------------------------------------------------------------------------------
1 | // Package v1 contains API Schema definitions for the mission-control v1 API group
2 | // +kubebuilder:object:generate=true
3 | // +groupName=mission-control.flanksource.com
4 | package v1
5 |
6 | import (
7 | "k8s.io/apimachinery/pkg/runtime/schema"
8 | "sigs.k8s.io/controller-runtime/pkg/scheme"
9 | )
10 |
11 | var (
12 | // GroupVersion is group version used to register these objects
13 | GroupVersion = schema.GroupVersion{Group: "mission-control.flanksource.com", Version: "v1"}
14 |
15 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme
16 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
17 |
18 | // AddToScheme adds the types in this group-version to the given scheme.
19 | AddToScheme = SchemeBuilder.AddToScheme
20 | )
21 |
--------------------------------------------------------------------------------
/api/v1/incident_rule_types.go:
--------------------------------------------------------------------------------
1 | package v1
2 |
3 | import (
4 | "github.com/flanksource/incident-commander/api"
5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
6 | )
7 |
8 | // IncidentRuleStatus defines the observed state of IncidentRule
9 | type IncidentRuleStatus struct {
10 | // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
11 | }
12 |
13 | //+kubebuilder:object:root=true
14 | //+kubebuilder:subresource:status
15 |
16 | // IncidentRule is the Schema for the IncidentRule API
17 | type IncidentRule struct {
18 | metav1.TypeMeta `json:",inline" yaml:",inline"`
19 | metav1.ObjectMeta `json:"metadata,omitempty" yaml:"metadata,omitempty"`
20 |
21 | Spec api.IncidentRuleSpec `json:"spec,omitempty" yaml:"spec,omitempty"`
22 | Status IncidentRuleStatus `json:"status,omitempty" yaml:"status,omitempty"`
23 | }
24 |
25 | //+kubebuilder:object:root=true
26 |
27 | // IncidentRuleList contains a list of IncidentRule
28 | type IncidentRuleList struct {
29 | metav1.TypeMeta `json:",inline"`
30 | metav1.ListMeta `json:"metadata,omitempty"`
31 | Items []IncidentRule `json:"items"`
32 | }
33 |
34 | func init() {
35 | SchemeBuilder.Register(&IncidentRule{}, &IncidentRuleList{})
36 | }
37 |
--------------------------------------------------------------------------------
/api/v1/notification_silence_types.go:
--------------------------------------------------------------------------------
1 | package v1
2 |
3 | import (
4 | "github.com/flanksource/duty/types"
5 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
6 | )
7 |
8 | // +kubebuilder:object:generate=true
9 | type NotificationSilenceSpec struct {
10 | Description *string `json:"description,omitempty"`
11 |
12 | // From time in RFC3339 format or just datetime
13 | From *string `json:"from,omitempty"`
14 |
15 | // Until time in RFC3339 format or just datetime
16 | Until *string `json:"until,omitempty"`
17 |
18 | Recursive bool `json:"recursive,omitempty"`
19 |
20 | // Filter evaluates whether to apply the silence. When provided, silence is applied only if filter evaluates to true
21 | Filter types.CelExpression `json:"filter,omitempty"`
22 |
23 | // List of resource selectors
24 | Selectors []types.ResourceSelector `json:"selectors,omitempty"`
25 | }
26 |
27 | // NotificationStatus defines the observed state of Notification
28 | type NotificationSilenceStatus struct {
29 | }
30 |
31 | // +kubebuilder:object:root=true
32 | // +kubebuilder:subresource:status
33 | //
34 | // NotificationSilence is the Schema for the managed Notification Silences
35 | type NotificationSilence struct {
36 | metav1.TypeMeta `json:",inline"`
37 | metav1.ObjectMeta `json:"metadata,omitempty"`
38 |
39 | Spec NotificationSilenceSpec `json:"spec,omitempty"`
40 | Status NotificationSilenceStatus `json:"status,omitempty" yaml:"status,omitempty"`
41 | }
42 |
43 | //+kubebuilder:object:root=true
44 |
45 | // NotificationSilenceList contains a list of Notification Silences
46 | type NotificationSilenceList struct {
47 | metav1.TypeMeta `json:",inline"`
48 | metav1.ListMeta `json:"metadata,omitempty"`
49 | Items []NotificationSilence `json:"items"`
50 | }
51 |
52 | func init() {
53 | SchemeBuilder.Register(&NotificationSilence{}, &NotificationSilenceList{})
54 | }
55 |
--------------------------------------------------------------------------------
/api/v1/playbook_actions_test.go:
--------------------------------------------------------------------------------
1 | package v1
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestNextRetryWait(t *testing.T) {
8 | tests := []struct {
9 | name string
10 | RetryCount int
11 | Retry PlaybookActionRetry
12 | ExpectedRange []float64
13 | ExpectedErr bool
14 | }{
15 | {
16 | name: "no jitter",
17 | RetryCount: 1,
18 | ExpectedRange: []float64{60, 60},
19 | Retry: PlaybookActionRetry{
20 | Limit: 1,
21 | Duration: "30s",
22 | Exponent: RetryExponent{
23 | Multiplier: 2,
24 | },
25 | Jitter: 0,
26 | },
27 | },
28 | {
29 | name: "no jitter second iteration",
30 | RetryCount: 2,
31 | ExpectedRange: []float64{120, 120},
32 | Retry: PlaybookActionRetry{
33 | Limit: 1,
34 | Duration: "30s",
35 | Exponent: RetryExponent{
36 | Multiplier: 2,
37 | },
38 | Jitter: 0,
39 | },
40 | },
41 | {
42 | name: "with jitter second iteration",
43 | RetryCount: 2,
44 | ExpectedRange: []float64{108, 132},
45 | Retry: PlaybookActionRetry{
46 | Limit: 1,
47 | Duration: "30s",
48 | Exponent: RetryExponent{
49 | Multiplier: 2,
50 | },
51 | Jitter: 10,
52 | },
53 | },
54 | }
55 |
56 | for _, tt := range tests {
57 | t.Run(tt.name, func(t *testing.T) {
58 | nextTime, err := tt.Retry.NextRetryWait(tt.RetryCount)
59 | if (err != nil) != tt.ExpectedErr {
60 | t.Errorf("expected error: %v, got error: %v", tt.ExpectedErr, err)
61 | }
62 |
63 | if nextTime.Seconds() < tt.ExpectedRange[0] || nextTime.Seconds() > tt.ExpectedRange[1] {
64 | t.Errorf("expected next time to be between %f and %f, got %v", tt.ExpectedRange[0], tt.ExpectedRange[1], nextTime)
65 | }
66 | })
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/application/suite_test.go:
--------------------------------------------------------------------------------
1 | package application
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | ginkgo "github.com/onsi/ginkgo/v2"
8 | . "github.com/onsi/gomega"
9 | "github.com/onsi/gomega/format"
10 | "github.com/samber/oops"
11 |
12 | "github.com/flanksource/duty/context"
13 | "github.com/flanksource/duty/tests/setup"
14 |
15 | // register event handlers
16 | _ "github.com/flanksource/incident-commander/incidents/responder"
17 | _ "github.com/flanksource/incident-commander/notification"
18 | )
19 |
20 | func TestApplication(t *testing.T) {
21 | RegisterFailHandler(ginkgo.Fail)
22 | ginkgo.RunSpecs(t, "Application")
23 | }
24 |
25 | var DefaultContext context.Context
26 |
27 | var _ = ginkgo.BeforeSuite(func() {
28 | format.RegisterCustomFormatter(func(value interface{}) (string, bool) {
29 | switch v := value.(type) {
30 | case error:
31 | if err, ok := oops.AsOops(v); ok {
32 | return fmt.Sprintf("%+v", err), true
33 | }
34 | }
35 |
36 | return "", false
37 | })
38 |
39 | DefaultContext = setup.BeforeSuiteFn()
40 | DefaultContext.Logger.SetLogLevel(DefaultContext.Properties().String("log.level", "info"))
41 | DefaultContext.Infof("%s", DefaultContext.String())
42 |
43 | })
44 |
45 | var _ = ginkgo.AfterSuite(func() {
46 | setup.AfterSuiteFn()
47 | })
48 |
--------------------------------------------------------------------------------
/application/testdata/azure-scrapeconfig.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: configs.flanksource.com/v1
3 | kind: ScrapeConfig
4 | metadata:
5 | uid: 123e4567-e89b-12d3-a456-426614174000
6 | name: azure
7 | namespace: mc
8 | spec:
9 | azure:
10 | - connection: connection://mc/azure-aditya
11 | subscriptionID: cf2ed699-d6c6-4ee3-965f-bf48fc64beda
12 | include:
13 | - users
14 | - groups
15 | - appRegistrations
16 | - appRoles
17 | - accessReviews
18 | - authMethods
19 | - enterpriseApps
--------------------------------------------------------------------------------
/application/testdata/azure.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Application
3 | metadata:
4 | uid: 2889c7b1-4cc7-442a-8fa7-e5333d0b4b58
5 | name: azure-flanksource
6 | namespace: mc
7 | spec:
8 | type: App Registration
9 | schedule: "@every 10m"
10 | properties:
11 | - label: Classification
12 | text: Confidential
13 | icon: shield
14 | - label: Criticality
15 | text: High
16 | icon: alert-triangle
17 | mapping:
18 | logins:
19 | - name: the-application
20 | types:
21 | - Azure::EnterpriseApplication
22 | roles:
23 | - search: type=Azure::Group name=sap-erp-group
24 | role: User
25 | - search: type=Azure::Group name=sap-erp-group-admins
26 | role: Admin
27 |
--------------------------------------------------------------------------------
/auth/admin_user.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "errors"
5 | "os"
6 |
7 | "github.com/flanksource/duty/context"
8 | "github.com/flanksource/duty/models"
9 | "gorm.io/gorm"
10 | )
11 |
12 | const (
13 | AdminName = "Admin"
14 | AdminEmail = "admin@local"
15 | DefaultAdminPassword = "admin"
16 | )
17 |
18 | func getDefaultAdminPassword() string {
19 | if password := os.Getenv("ADMIN_PASSWORD"); password != "" {
20 | return password
21 | }
22 | return DefaultAdminPassword
23 | }
24 |
25 | func GetOrCreateAdminUser(ctx context.Context) (*models.Person, error) {
26 | var admin models.Person
27 | if err := ctx.DB().Model(admin).Where("name = ? OR email = ?", AdminName, AdminEmail).First(&admin).Error; err != nil {
28 | if errors.Is(err, gorm.ErrRecordNotFound) {
29 | admin = models.Person{
30 | Name: AdminName,
31 | Email: AdminEmail,
32 | }
33 | if err := ctx.DB().Create(&admin).Error; err != nil {
34 | return nil, err
35 | }
36 | } else {
37 | return nil, err
38 | }
39 | }
40 |
41 | return &admin, nil
42 | }
43 |
--------------------------------------------------------------------------------
/auth/basic.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "net/http"
5 | "strings"
6 |
7 | "github.com/flanksource/commons/logger"
8 | "github.com/flanksource/duty/context"
9 | "github.com/flanksource/duty/models"
10 | "github.com/labstack/echo/v4"
11 | "github.com/labstack/echo/v4/middleware"
12 | "github.com/tg123/go-htpasswd"
13 | )
14 |
15 | var HtpasswdFile string
16 |
17 | var checker *htpasswd.File
18 |
19 | func UseBasic(e *echo.Echo) {
20 | var err error
21 | checker, err = htpasswd.New(HtpasswdFile, htpasswd.DefaultSystems, nil)
22 | if err != nil {
23 | panic(err)
24 | }
25 |
26 | e.Use(middleware.BasicAuthWithConfig(middleware.BasicAuthConfig{
27 | Skipper: canSkipAuth,
28 | Realm: "Mission Control",
29 | Validator: func(user, pass string, c echo.Context) (bool, error) {
30 | if !checker.Match(user, pass) {
31 | return false, nil
32 | }
33 |
34 | ctx := c.Request().Context().(context.Context)
35 | user = strings.ToLower(user)
36 | var person models.Person
37 | if err := ctx.DB().Where("LOWER(name) = ? or LOWER(email) = ?", user, user).First(&person).Error; err != nil {
38 | logger.Warnf("user authenticated via htpasswd, but not found in the db: %s", user)
39 | return false, c.String(http.StatusUnauthorized, "User not found")
40 | }
41 |
42 | if err := InjectToken(ctx, c, &person, ""); err != nil {
43 | return false, err
44 | }
45 |
46 | ctx = ctx.WithUser(&person)
47 |
48 | c.SetRequest(c.Request().WithContext(ctx))
49 |
50 | return true, nil
51 | },
52 | }))
53 | }
54 |
--------------------------------------------------------------------------------
/auth/mock.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/flanksource/duty/context"
7 | "github.com/flanksource/duty/models"
8 | "github.com/labstack/echo/v4"
9 | )
10 |
11 | // mockAuthMiddleware doesn't actually authenticate since we never store auth data.
12 | // It simply ensures that the requested user exists in the DB and then attaches the
13 | // users's ID to the context.
14 | func MockAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
15 | return func(c echo.Context) error {
16 | ctx := c.Request().Context().(context.Context)
17 | logger := ctx.Logger.Named("auth")
18 | name, _, ok := c.Request().BasicAuth()
19 | if !ok {
20 | logger.Warnf("no basic authentication")
21 | return next(c)
22 | }
23 |
24 | var person models.Person
25 | if err := ctx.DB().Where("name = ? or email = ?", name, name).First(&person).Error; err != nil {
26 | logger.Warnf("user %s not found", name)
27 | return c.String(http.StatusUnauthorized, "Unauthorized - User not found")
28 | }
29 |
30 | if err := InjectToken(ctx, c, &person, ""); err != nil {
31 | return err
32 | }
33 |
34 | ctx = ctx.WithUser(&person)
35 |
36 | c.SetRequest(c.Request().WithContext(ctx))
37 | return next(c)
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/auth/templates.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "fmt"
5 | "html/template"
6 |
7 | "github.com/flanksource/duty/shutdown"
8 | )
9 |
10 | var inviteUserTemplate *template.Template
11 |
12 | func init() {
13 | parsed, err := template.New("email").Parse(`
14 | Welcome to Mission Control
15 |
16 | Hello {{.firstName}},
17 | Please visit {{.link}} to complete registration and use the code: {{.code}}
`)
18 | if err != nil {
19 | shutdown.ShutdownAndExit(1, fmt.Sprintf("failed to parse invitation email template: %v", err))
20 | }
21 |
22 | inviteUserTemplate = parsed
23 | }
24 |
--------------------------------------------------------------------------------
/catalog/controllers.go:
--------------------------------------------------------------------------------
1 | package catalog
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/flanksource/commons/logger"
7 | "github.com/flanksource/duty/api"
8 | "github.com/flanksource/duty/context"
9 | "github.com/flanksource/duty/query"
10 | "github.com/flanksource/duty/rbac/policy"
11 | echoSrv "github.com/flanksource/incident-commander/echo"
12 | "github.com/flanksource/incident-commander/rbac"
13 | "github.com/labstack/echo/v4"
14 | )
15 |
16 | func init() {
17 | echoSrv.RegisterRoutes(RegisterRoutes)
18 | }
19 |
20 | func RegisterRoutes(e *echo.Echo) {
21 | logger.Infof("Registering /catalog routes")
22 |
23 | apiGroup := e.Group("/catalog", rbac.Catalog(policy.ActionRead))
24 | apiGroup.POST("/summary", SearchConfigSummary, echoSrv.RLSMiddleware)
25 |
26 | apiGroup.POST("/changes", SearchCatalogChanges, echoSrv.RLSMiddleware)
27 | // Deprecated. Use POST
28 | apiGroup.GET("/changes", SearchCatalogChanges, echoSrv.RLSMiddleware)
29 | }
30 |
31 | func SearchCatalogChanges(c echo.Context) error {
32 | var req query.CatalogChangesSearchRequest
33 | if err := c.Bind(&req); err != nil {
34 | return api.WriteError(c, api.Errorf(api.EINVALID, "invalid request: %v", err))
35 | }
36 |
37 | ctx := c.Request().Context().(context.Context)
38 |
39 | response, err := query.FindCatalogChanges(ctx, req)
40 | if err != nil {
41 | return api.WriteError(c, err)
42 | }
43 |
44 | return c.JSON(http.StatusOK, response)
45 | }
46 |
47 | func SearchConfigSummary(c echo.Context) error {
48 | var req query.ConfigSummaryRequest
49 | if err := c.Bind(&req); err != nil {
50 | return api.WriteError(c, api.Errorf(api.EINVALID, "invalid request: %v", err))
51 | }
52 |
53 | ctx := c.Request().Context().(context.Context)
54 |
55 | response, err := query.ConfigSummary(ctx, req)
56 | if err != nil {
57 | return api.WriteError(c, err)
58 | }
59 |
60 | return c.JSON(http.StatusOK, response)
61 | }
62 |
--------------------------------------------------------------------------------
/cmd/offline.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "github.com/flanksource/commons/logger"
5 | "github.com/flanksource/duty/postgrest"
6 | "github.com/spf13/cobra"
7 | )
8 |
9 | var GoOffline = &cobra.Command{
10 | Use: "go-offline",
11 | Long: "Download all dependencies so that incident-commander can work without an internet connection",
12 | PersistentPreRun: func(cmd *cobra.Command, args []string) {
13 | },
14 | Run: func(cmd *cobra.Command, args []string) {
15 | if err := postgrest.GoOffline(); err != nil {
16 | logger.Fatalf("Failed to go offline: %+v", err)
17 | }
18 | },
19 | }
20 |
--------------------------------------------------------------------------------
/components/logs.go:
--------------------------------------------------------------------------------
1 | package components
2 |
3 | import (
4 | gocontext "context"
5 | "encoding/json"
6 | "io"
7 | "net/url"
8 |
9 | "github.com/flanksource/commons/http"
10 | "github.com/flanksource/duty/context"
11 |
12 | "github.com/flanksource/incident-commander/api"
13 | )
14 |
15 | func GetLogsByComponent(ctx context.Context, componentID, start, end string) (api.ComponentLogs, error) {
16 | var logs api.LogsResponse
17 | var row struct {
18 | Name string
19 | ExternalID string
20 | Type string
21 | }
22 | err := ctx.DB().Table("components").Select("name", "external_id", "type").Where("id = ?", componentID).Find(&row).Error
23 | if err != nil {
24 | return api.ComponentLogs{}, err
25 | }
26 |
27 | type payloadBody struct {
28 | ID string `json:"id"`
29 | Type string `json:"type"`
30 | Start string `json:"start"`
31 | End string `json:"end"`
32 | }
33 |
34 | payload := payloadBody{
35 | ID: row.ExternalID,
36 | Type: row.Type,
37 | Start: start,
38 | End: end,
39 | }
40 | payloadBytes, err := json.Marshal(&payload)
41 | if err != nil {
42 | return api.ComponentLogs{}, err
43 | }
44 |
45 | endpoint, err := url.JoinPath(api.ApmHubPath, "/search")
46 | if err != nil {
47 | return api.ComponentLogs{}, err
48 | }
49 |
50 | resp, err := http.NewClient().R(gocontext.Background()).Header("Content-Type", "application/json").Post(endpoint, payloadBytes)
51 | if err != nil {
52 | return api.ComponentLogs{}, err
53 | }
54 |
55 | defer resp.Body.Close()
56 | body, err := io.ReadAll(resp.Body)
57 | if err != nil {
58 | return api.ComponentLogs{}, err
59 | }
60 |
61 | err = json.Unmarshal(body, &logs)
62 | if err != nil {
63 | return api.ComponentLogs{}, err
64 | }
65 |
66 | componentLogs := api.ComponentLogs{
67 | ID: componentID,
68 | Name: row.Name,
69 | Type: row.Type,
70 | Logs: logs.Results,
71 | }
72 |
73 | return componentLogs, nil
74 | }
75 |
--------------------------------------------------------------------------------
/config/schemas/openapi.go:
--------------------------------------------------------------------------------
1 | package schemas
2 |
3 | import (
4 | "embed"
5 | "net/http"
6 |
7 | "github.com/samber/oops"
8 | "github.com/xeipuuv/gojsonschema"
9 | )
10 |
11 | //go:embed *
12 | var Schemas embed.FS
13 |
14 | func ValidatePlaybookSpec(schema []byte) (error, error) {
15 | return ValidateSpec("playbook-spec.schema.json", schema)
16 | }
17 |
18 | func ValidateSpec(path string, schema []byte) (error, error) {
19 | var playbookSchemaLoader = gojsonschema.NewReferenceLoaderFileSystem("file:///"+path, http.FS(Schemas))
20 | documentLoader := gojsonschema.NewBytesLoader(schema)
21 | result, err := gojsonschema.Validate(playbookSchemaLoader, documentLoader)
22 | if err != nil {
23 | return nil, oops.Wrap(err)
24 | }
25 |
26 | if len(result.Errors()) != 0 {
27 | return oops.Errorf("spec is invalid: %v", result.Errors()), nil
28 | }
29 |
30 | return nil, nil
31 | }
32 |
--------------------------------------------------------------------------------
/connection/controllers.go:
--------------------------------------------------------------------------------
1 | package connection
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "net/http"
7 |
8 | "github.com/flanksource/commons/logger"
9 | dutyAPI "github.com/flanksource/duty/api"
10 | "github.com/flanksource/duty/context"
11 | "github.com/flanksource/duty/models"
12 | "github.com/flanksource/duty/rbac/policy"
13 | echoSrv "github.com/flanksource/incident-commander/echo"
14 | "github.com/labstack/echo/v4"
15 | "gorm.io/gorm"
16 |
17 | "github.com/flanksource/incident-commander/rbac"
18 | )
19 |
20 | func init() {
21 | echoSrv.RegisterRoutes(RegisterRoutes)
22 | }
23 |
24 | func RegisterRoutes(e *echo.Echo) {
25 | logger.Infof("Registering /connection routes")
26 |
27 | prefix := "connection"
28 | connectionGroup := e.Group(fmt.Sprintf("/%s", prefix))
29 | connectionGroup.POST("/test/:id", TestConnection, rbac.Authorization(policy.ObjectConnection, policy.ActionUpdate))
30 |
31 | }
32 |
33 | func TestConnection(c echo.Context) error {
34 | ctx := c.Request().Context().(context.Context)
35 |
36 | var id = c.Param("id")
37 |
38 | var connection models.Connection
39 | if err := ctx.DB().Where("id = ?", id).First(&connection).Error; err != nil {
40 | if errors.Is(err, gorm.ErrRecordNotFound) {
41 | return dutyAPI.WriteError(c, dutyAPI.Errorf(dutyAPI.ENOTFOUND, "connection was not found"))
42 | }
43 |
44 | return dutyAPI.WriteError(c, err)
45 | }
46 |
47 | if err := Test(ctx, &connection); err != nil {
48 | return dutyAPI.WriteError(c, err)
49 | }
50 |
51 | return c.JSON(http.StatusOK, dutyAPI.HTTPSuccess{Message: "ok"})
52 | }
53 |
--------------------------------------------------------------------------------
/db/artifacts.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/flanksource/duty/context"
7 | "github.com/flanksource/duty/models"
8 | "github.com/google/uuid"
9 | "gorm.io/gorm"
10 | )
11 |
12 | func FindArtifact(ctx context.Context, id uuid.UUID) (*models.Artifact, error) {
13 | var artifact models.Artifact
14 | if err := ctx.DB().Where("id = ?", id).First(&artifact).Error; err != nil {
15 | if errors.Is(err, gorm.ErrRecordNotFound) {
16 | return nil, nil
17 | }
18 |
19 | return nil, err
20 | }
21 |
22 | return &artifact, nil
23 | }
24 |
--------------------------------------------------------------------------------
/db/canaries.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/flanksource/duty/context"
7 | "github.com/flanksource/incident-commander/api"
8 | "github.com/google/uuid"
9 | )
10 |
11 | func GetCanariesOfAgent(ctx context.Context, agentID uuid.UUID, since time.Time) (*api.CanaryPullResponse, error) {
12 | var now time.Time
13 | if err := ctx.DB().Raw("SELECT NOW()").Scan(&now).Error; err != nil {
14 | return nil, err
15 | }
16 |
17 | q := ctx.DB().Where("agent_id = ?", agentID).Where("updated_at <= ?", now)
18 | if !since.IsZero() {
19 | q = q.Where("updated_at > ?", since)
20 | }
21 |
22 | response := &api.CanaryPullResponse{
23 | Before: now,
24 | }
25 | err := q.Find(&response.Canaries).Error
26 | return response, err
27 | }
28 |
--------------------------------------------------------------------------------
/db/config.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/flanksource/duty/context"
7 | "github.com/flanksource/duty/models"
8 | "github.com/google/uuid"
9 | )
10 |
11 | func LookupRelatedConfigIDs(ctx context.Context, configID string, maxDepth int) ([]string, error) {
12 | var configIDs []string
13 |
14 | var rows []struct {
15 | ChildID string
16 | ParentID string
17 | }
18 | if err := ctx.DB().Raw(`SELECT child_id, parent_id FROM lookup_config_children(?, ?)`, configID, maxDepth).
19 | Scan(&rows).Error; err != nil {
20 | return configIDs, err
21 | }
22 | for _, row := range rows {
23 | configIDs = append(configIDs, row.ChildID, row.ParentID)
24 | }
25 |
26 | var relatedRows []string
27 | if err := ctx.DB().Raw(`SELECT id FROM lookup_config_relations(?)`, configID).
28 | Scan(&relatedRows).Error; err != nil {
29 | return configIDs, err
30 | }
31 | configIDs = append(configIDs, relatedRows...)
32 |
33 | return configIDs, nil
34 | }
35 |
36 | func GetScrapeConfigsOfAgent(ctx context.Context, agentID uuid.UUID, since time.Time) ([]models.ConfigScraper, error) {
37 | var response []models.ConfigScraper
38 | err := ctx.DB().Where("agent_id = ?", agentID).Where("updated_at > ?", since).Order("updated_at").Find(&response).Error
39 | return response, err
40 | }
41 |
--------------------------------------------------------------------------------
/db/evidences.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "github.com/flanksource/incident-commander/api"
5 | "github.com/google/uuid"
6 |
7 | "github.com/flanksource/commons/logger"
8 | "github.com/flanksource/duty/context"
9 | "github.com/flanksource/duty/models"
10 | )
11 |
12 | type Hypothesis struct {
13 | api.Hypothesis
14 | Incident api.Incident `json:"incident,omitempty" gorm:"foreignKey:IncidentID;references:ID"`
15 | }
16 |
17 | type EvidenceScriptInput struct {
18 | api.Evidence
19 | ConfigAnalysis api.ConfigAnalysis `json:"config_analysis,omitempty" gorm:"foreignKey:ConfigAnalysisID;references:ID"`
20 | ConfigItem api.ConfigItem `json:"config,omitempty" gorm:"foreignKey:ConfigID;references:ID"`
21 | Check api.Check `json:"check,omitempty" gorm:"foreignKey:CheckID;references:ID"`
22 | Component models.Component `json:"component,omitempty"`
23 | Hypothesis Hypothesis
24 | }
25 |
26 | func GetEvidenceScripts(ctx context.Context) []EvidenceScriptInput {
27 | var evidences []EvidenceScriptInput
28 | incidentsSubQuery := ctx.DB().Table("incidents").Select("id").Where("closed IS NULL")
29 | hypothesesSubQuery := ctx.DB().Table("hypotheses").Select("id").Where("incident_id IN (?)", incidentsSubQuery)
30 | err := ctx.DB().Table("evidences").
31 | Joins("ConfigItem").
32 | Joins("ConfigAnalysis").
33 | Joins("Check").
34 | Joins("Component").
35 | Joins("Hypothesis").
36 | Preload("Hypothesis.Incident").
37 | Where("evidences.hypothesis_id IN (?)", hypothesesSubQuery).
38 | Where("evidences.script IS NOT NULL").
39 | Where("evidences.script != ''").
40 | Find(&evidences).Error
41 |
42 | if err != nil {
43 | logger.Errorf("error fetching the evidences: %v", err)
44 | return nil
45 | }
46 |
47 | return evidences
48 | }
49 |
50 | func UpdateEvidenceScriptResult(ctx context.Context, id uuid.UUID, done bool, result string) error {
51 | return ctx.DB().Table("evidences").Where("id = ?", id).
52 | Updates(map[string]any{"done": done, "script_result": result}).
53 | Error
54 | }
55 |
--------------------------------------------------------------------------------
/db/models/incident.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "time"
5 |
6 | jsoniter "github.com/json-iterator/go"
7 |
8 | "github.com/flanksource/duty/types"
9 | "github.com/flanksource/incident-commander/api"
10 | "github.com/google/uuid"
11 | "gorm.io/gorm"
12 | )
13 |
14 | // Gorm entity for the hpothesis table
15 | type IncidentRule struct {
16 | ID *uuid.UUID `json:"id,omitempty" gorm:"default:generate_ulid()"`
17 | Name string `json:"name,omitempty"`
18 | Source string `json:"source,omitempty"`
19 | // TODO make this a custom GORM type
20 | Spec types.JSON `json:"spec,omitempty"`
21 | CreatedAt *time.Time `json:"created_at,omitempty"`
22 | UpdatedAt *time.Time `json:"updated_at,omitempty"`
23 | CreatedBy *uuid.UUID `json:"created_by,omitempty"`
24 | spec *api.IncidentRuleSpec `json:"-" gorm:"-"`
25 | }
26 |
27 | func (incidentRule *IncidentRule) GetSpec() (*api.IncidentRuleSpec, error) {
28 | if incidentRule.spec != nil {
29 | return incidentRule.spec, nil
30 | }
31 | incidentRule.spec = &api.IncidentRuleSpec{}
32 | if err := jsoniter.Unmarshal(incidentRule.Spec, incidentRule.spec); err != nil {
33 | return nil, err
34 | }
35 | return incidentRule.spec, nil
36 | }
37 |
38 | func (incidentRule *IncidentRule) BeforeCreate(tx *gorm.DB) (err error) {
39 | if incidentRule.CreatedBy == nil {
40 | incidentRule.CreatedBy = api.SystemUserID
41 | }
42 | return
43 | }
44 |
--------------------------------------------------------------------------------
/db/models/team.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/flanksource/duty/types"
7 | "github.com/google/uuid"
8 | )
9 |
10 | type Team struct {
11 | ID uuid.UUID `gorm:"default:generate_ulid();primaryKey"`
12 | Name string `gorm:"not null"`
13 | Icon *string
14 | Spec types.JSONMap
15 | Source *string
16 | CreatedBy uuid.UUID `gorm:"not null"`
17 | CreatedAt time.Time `gorm:"type:timestamp with time zone;default:now();not null"`
18 | UpdatedAt time.Time `gorm:"type:timestamp with time zone;default:now();not null"`
19 | DeletedAt *time.Time `gorm:"type:timestamp with time zone"`
20 | }
21 |
22 | type TeamMember struct {
23 | TeamID uuid.UUID `gorm:"not null"`
24 | PersonID uuid.UUID `gorm:"not null"`
25 | }
26 |
--------------------------------------------------------------------------------
/db/properties.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "github.com/flanksource/duty/context"
5 | "github.com/flanksource/duty/models"
6 | )
7 |
8 | func GetProperties(ctx context.Context) ([]models.AppProperty, error) {
9 | var properties []models.AppProperty
10 | err := ctx.DB().Find(&properties).Error
11 | return properties, err
12 | }
13 |
--------------------------------------------------------------------------------
/db/suite_test.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/flanksource/duty/context"
7 | "github.com/flanksource/duty/tests/setup"
8 | ginkgo "github.com/onsi/ginkgo/v2"
9 | . "github.com/onsi/gomega"
10 | )
11 |
12 | func TestDB(t *testing.T) {
13 | RegisterFailHandler(ginkgo.Fail)
14 | ginkgo.RunSpecs(t, "DB")
15 | }
16 |
17 | var (
18 | DefaultContext context.Context
19 | )
20 |
21 | var _ = ginkgo.BeforeSuite(func() {
22 | DefaultContext = setup.BeforeSuiteFn()
23 | })
24 |
25 | var _ = ginkgo.AfterSuite(setup.AfterSuiteFn)
26 |
--------------------------------------------------------------------------------
/deploy/apm-hub/kustomization.yaml:
--------------------------------------------------------------------------------
1 | namespace: incident-commander
2 | bases:
3 | - "https://github.com/flanksource/apm-hub/deploy/"
4 | patchesStrategicMerge:
5 | - |
6 | apiVersion: v1
7 | kind: Namespace
8 | metadata:
9 | name: apm-hub
10 | labels:
11 | control-plane: apm-hub
12 | $patch: delete
--------------------------------------------------------------------------------
/deploy/canary-checker/kustomization.yaml:
--------------------------------------------------------------------------------
1 | namespace: incident-commander
2 | resources:
3 | - "postgres-secret.yaml"
4 | - https://raw.githubusercontent.com/flanksource/canary-checker/v0.38.74/config/deploy/manifests.yaml
5 | patchesStrategicMerge:
6 | - patch.yaml
--------------------------------------------------------------------------------
/deploy/canary-checker/patch.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | labels:
5 | control-plane: canary-checker
6 | name: canary-checker
7 | namespace: canary-checker
8 | spec:
9 | replicas: 1
10 | selector:
11 | matchLabels:
12 | control-plane: canary-checker
13 | template:
14 | metadata:
15 | labels:
16 | control-plane: canary-checker
17 | spec:
18 | containers:
19 | - name: canary-checker
20 | env:
21 | - name: DOCKER_API_VERSION
22 | value: "1.39"
23 | - name: DB_URL
24 | valueFrom:
25 | secretKeyRef:
26 | name: canary-checker-postgres-connection-string
27 | key: connection-string
28 | ---
29 | apiVersion: v1
30 | kind: Namespace
31 | metadata:
32 | name: canary-checker
33 | labels:
34 | control-plane: canary-checker
35 | $patch: delete
36 | ---
37 | apiVersion: networking.k8s.io/v1
38 | kind: Ingress
39 | metadata:
40 | annotations:
41 | kubernetes.io/tls-acme: "true"
42 | name: canary-checker
43 | namespace: canary-checker
44 | $patch: delete
45 | ---
46 | apiVersion: monitoring.coreos.com/v1
47 | kind: ServiceMonitor
48 | metadata:
49 | labels:
50 | control-plane: canary-checker
51 | name: canary-checker-monitor
52 | namespace: canary-checker
53 | $patch: delete
--------------------------------------------------------------------------------
/deploy/canary-checker/postgres-secret.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | data:
3 | connection-string: cG9zdGdyZXNxbDovL3Bvc3RncmVzOlNvbTNQYXNzd2RAcG9zdGdyZXMtZGIvY2FuYXJ5X2NoZWNrZXI=
4 | kind: Secret
5 | metadata:
6 | creationTimestamp: null
7 | name: canary-checker-postgres-connection-string
--------------------------------------------------------------------------------
/deploy/config-db/kustomization.yaml:
--------------------------------------------------------------------------------
1 | namespace: incident-commander
2 | bases:
3 | - "https://github.com/flanksource/config-db/deploy"
4 | resources:
5 | - postgres-secret.yaml
6 | patchesStrategicMerge:
7 | - patch.yaml
--------------------------------------------------------------------------------
/deploy/config-db/patch.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: apps/v1
3 | kind: Deployment
4 | metadata:
5 | name: config-db
6 | labels:
7 | control-plane: config-db
8 | spec:
9 | selector:
10 | matchLabels:
11 | control-plane: config-db
12 | replicas: 1
13 | template:
14 | metadata:
15 | labels:
16 | control-plane: config-db
17 | spec:
18 | serviceAccountName: config-db-sa
19 | containers:
20 | - name: config-db
21 | env:
22 | - name: DB_URL
23 | valueFrom:
24 | secretKeyRef:
25 | name: config-db-postgres-connection-string
26 | key: connection-string
27 | ---
28 | apiVersion: v1
29 | kind: Namespace
30 | metadata:
31 | name: config-db
32 | labels:
33 | control-plane: config-db
34 | $patch: delete
--------------------------------------------------------------------------------
/deploy/config-db/postgres-secret.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | data:
3 | connection-string: cG9zdGdyZXNxbDovL3Bvc3RncmVzOlNvbTNQYXNzd2RAcG9zdGdyZXMtZGIvY29uZmlnX2Ri
4 | kind: Secret
5 | metadata:
6 | name: config-db-postgres-connection-string
--------------------------------------------------------------------------------
/deploy/incident-commander/kustomization.yaml:
--------------------------------------------------------------------------------
1 | namespace: incident-commander
2 | resources:
3 | - "postgres-secret.yaml"
4 | - "manager.yaml"
--------------------------------------------------------------------------------
/deploy/incident-commander/manager.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: apps/v1
3 | kind: Deployment
4 | metadata:
5 | name: incident-commander
6 | labels:
7 | control-plane: incident-commander
8 | spec:
9 | selector:
10 | matchLabels:
11 | control-plane: incident-commander
12 | replicas: 1
13 | template:
14 | metadata:
15 | labels:
16 | control-plane: incident-commander
17 | spec:
18 | containers:
19 | - name: incident-commander
20 | image: docker.io/flanksource/incident-commander:latest
21 | env:
22 | - name: DB_URL
23 | valueFrom:
24 | secretKeyRef:
25 | name: incident-commander-postgres-connection-string
26 | key: connection-string
27 | command:
28 | - /app/incident-commander
29 | args:
30 | - serve
31 | - -vvv
32 | - --apm-hub=http://apm-hub:8080
33 | - --canary-checker=http://canary-checker:8080
34 | - --config-db=http://config-db:8080
35 | resources:
36 | requests:
37 | cpu: 200m
38 | memory: 200Mi
39 | limits:
40 | memory: 512Mi
41 | cpu: 500m
42 | ---
43 | apiVersion: v1
44 | kind: Service
45 | metadata:
46 | labels:
47 | control-plane: incident-commander
48 | name: incident-commander
49 | namespace: incident-commander
50 | spec:
51 | ports:
52 | - port: 8080
53 | protocol: TCP
54 | targetPort: 8080
55 | selector:
56 | control-plane: incident-commander
--------------------------------------------------------------------------------
/deploy/incident-commander/postgres-secret.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | data:
3 | connection-string: cG9zdGdyZXNxbDovL3Bvc3RncmVzOlNvbTNQYXNzd2RAcG9zdGdyZXMtZGIvaW5jaWRlbnRfY29tbWFuZGVy
4 | kind: Secret
5 | metadata:
6 | creationTimestamp: null
7 | name: incident-commander-postgres-connection-string
--------------------------------------------------------------------------------
/deploy/kustomization.yaml:
--------------------------------------------------------------------------------
1 | resources:
2 | - namespace.yaml
3 | bases:
4 | - postgres
5 | - apm-hub
6 | - config-db
7 | - canary-checker
8 | - incident-commander
--------------------------------------------------------------------------------
/deploy/namespace.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Namespace
3 | metadata:
4 | name: incident-commander
5 | labels:
6 | control-plane: incident-commander
--------------------------------------------------------------------------------
/deploy/postgres/kustomization.yaml:
--------------------------------------------------------------------------------
1 | namespace: incident-commander
2 | resources:
3 | - "postgres-init-script.yaml"
4 | - "postgres.yaml"
--------------------------------------------------------------------------------
/deploy/postgres/postgres-init-script.yaml:
--------------------------------------------------------------------------------
1 | ## Adding init script to add more databases for the different comonents
2 | ## See: https://hub.docker.com/_/postgres #Initialization scripts for more info
3 | apiVersion: v1
4 | kind: ConfigMap
5 | metadata:
6 | name: postgres-init-script
7 | data:
8 | init-user-db.sh: |
9 | #!/bin/bash
10 | set -e
11 |
12 | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
13 | CREATE DATABASE canary_checker;
14 | CREATE DATABASE config_db;
15 | CREATE DATABASE incident_commander;
16 | GRANT ALL PRIVILEGES ON DATABASE canary_checker TO "$POSTGRES_USER";
17 | GRANT ALL PRIVILEGES ON DATABASE config_db TO "$POSTGRES_USER";
18 | GRANT ALL PRIVILEGES ON DATABASE incident_commander TO "$POSTGRES_USER";
19 | EOSQL
--------------------------------------------------------------------------------
/deploy/postgres/postgres.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | data:
3 | password: U29tM1Bhc3N3ZA==
4 | kind: Secret
5 | metadata:
6 | name: postgres-secret-config
7 | ---
8 | apiVersion: v1
9 | kind: PersistentVolumeClaim
10 | metadata:
11 | name: postgres-pv-claim
12 | spec:
13 | accessModes:
14 | - ReadWriteOnce
15 | resources:
16 | requests:
17 | storage: 50Gi
18 | ---
19 | apiVersion: apps/v1
20 | kind: Deployment
21 | metadata:
22 | name: postgres
23 | spec:
24 | replicas: 1
25 | selector:
26 | matchLabels:
27 | app: postgres
28 | type: database
29 | template:
30 | metadata:
31 | labels:
32 | app: postgres
33 | type: database
34 | spec:
35 | volumes:
36 | - name: postgres-pv-storage
37 | persistentVolumeClaim:
38 | claimName: postgres-pv-claim
39 | - name: init-script
40 | configMap:
41 | name: postgres-init-script
42 | containers:
43 | - name: postgres
44 | image: postgres:11
45 | imagePullPolicy: IfNotPresent
46 | ports:
47 | - containerPort: 5432
48 | env:
49 | - name: POSTGRES_PASSWORD
50 | valueFrom:
51 | secretKeyRef:
52 | name: postgres-secret-config
53 | key: password
54 | - name: PGDATA
55 | value: /var/lib/postgresql/data/pgdata
56 | volumeMounts:
57 | - mountPath: /var/lib/postgresql/data
58 | name: postgres-pv-storage
59 | - name: init-script
60 | mountPath: /docker-entrypoint-initdb.d/init-user-db.sh
61 | subPath: init-user-db.sh
62 | ---
63 | apiVersion: v1
64 | kind: Service
65 | metadata:
66 | name: postgres-db
67 | spec:
68 | selector:
69 | app: postgres
70 | type: database
71 | ports:
72 | - protocol: TCP
73 | port: 5432
74 | targetPort: 5432
--------------------------------------------------------------------------------
/echo/search.go:
--------------------------------------------------------------------------------
1 | package echo
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 |
7 | "github.com/flanksource/duty/api"
8 | "github.com/flanksource/duty/context"
9 | "github.com/flanksource/duty/query"
10 | echov4 "github.com/labstack/echo/v4"
11 | )
12 |
13 | func SearchResources(c echov4.Context) error {
14 | ctx := c.Request().Context().(context.Context)
15 |
16 | var request query.SearchResourcesRequest
17 | if err := json.NewDecoder(c.Request().Body).Decode(&request); err != nil {
18 | return api.WriteError(c, api.Errorf(api.EINVALID, "%s", err.Error()))
19 | }
20 |
21 | response, err := query.SearchResources(ctx, request)
22 | if err != nil {
23 | return api.WriteError(c, err)
24 | }
25 |
26 | return c.JSON(http.StatusOK, response)
27 | }
28 |
--------------------------------------------------------------------------------
/events/ring.go:
--------------------------------------------------------------------------------
1 | package events
2 |
3 | import (
4 | "sync"
5 |
6 | "github.com/flanksource/duty/models"
7 | )
8 |
9 | type EventRing struct {
10 | size int
11 | events map[string][]map[string]any
12 | mu *sync.RWMutex
13 | }
14 |
15 | func NewEventRing(size int) *EventRing {
16 | return &EventRing{
17 | size: size,
18 | events: make(map[string][]map[string]any),
19 | mu: &sync.RWMutex{},
20 | }
21 | }
22 |
23 | func (t *EventRing) Add(event models.Event, env map[string]any) {
24 | t.mu.Lock()
25 | defer t.mu.Unlock()
26 |
27 | if _, ok := t.events[event.Name]; !ok {
28 | t.events[event.Name] = make([]map[string]any, 0)
29 | }
30 |
31 | t.events[event.Name] = append(t.events[event.Name], map[string]any{
32 | "event": event,
33 | "env": env,
34 | })
35 |
36 | if len(t.events[event.Name]) > t.size {
37 | t.events[event.Name] = t.events[event.Name][1:]
38 | }
39 | }
40 |
41 | func (t *EventRing) Get() map[string][]map[string]any {
42 | return t.events
43 | }
44 |
--------------------------------------------------------------------------------
/fixtures/applications/sap-erp.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Application
3 | metadata:
4 | name: sap-erp
5 | namespace: mc
6 | spec:
7 | type: ERP
8 | schedule: "@every 1h"
9 | properties:
10 | - label: Classification
11 | order: 1
12 | text: Confidential
13 | icon: shield
14 | - label: Criticality
15 | order: 2
16 | text: High
17 | icon: alert-triangle
18 | - label: Usage
19 | order: 3
20 | text: Internal
21 | icon: globe
22 | - label: Source
23 | order: 4
24 | text: COTS
25 | icon: box
26 | mapping:
27 | logins:
28 | - search: type=Azure::EnterpriseApplication name="SAP-ERP"
29 | accessReviews:
30 | - search: type=Sailpoint::Role name=SAP ERP*
31 | roles:
32 | - search: type=Azure::Group name=sap-erp-group
33 | role: User
34 | - search: type=Azure::Group name=sap-erp-group-admins
35 | role: Admin
36 | environments:
37 | "Prod":
38 | - search: type=AWS::*
39 | tagSelector: account-name='flanksource'
40 | purpose: primary
41 | "Non-Prod":
42 | - search: type=AWS::*
43 | tagSelector: account-name='flanksource'
44 | purpose: backup
45 | datasources:
46 | - search: type=AWS::RDS,AWS::S3,AWS::EFS account=12345
47 |
--------------------------------------------------------------------------------
/fixtures/connections/awskms.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | # yaml-language-server: $schema=../../config/schemas/connection.schema.json
3 | apiVersion: mission-control.flanksource.com/v1
4 | kind: Connection
5 | metadata:
6 | name: flanksource-awskms
7 | spec:
8 | awskms:
9 | keyID: arn:aws:kms:eu-west-1:123123123123:alias/sops-key
10 | region: eu-west-1
11 | accessKey:
12 | valueFrom:
13 | secretKeyRef:
14 | name: aws-flanksource
15 | key: AWS_ACCESS_KEY_ID
16 | secretKey:
17 | valueFrom:
18 | secretKeyRef:
19 | name: aws-flanksource
20 | key: AWS_SECRET_ACCESS_KEY
--------------------------------------------------------------------------------
/fixtures/connections/gcpkms.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | # yaml-language-server: $schema=../../config/schemas/connection.schema.json
3 | apiVersion: mission-control.flanksource.com/v1
4 | kind: Connection
5 | metadata:
6 | name: flanksource-gcpkms
7 | spec:
8 | gcpkms:
9 | keyID: projects/flanksource-sandbox/locations/global/keyRings/sops-keyring/cryptoKeys/sops-key
10 | certificate:
11 | valueFrom:
12 | secretKeyRef:
13 | name: flanksource-gcloud
14 | key: credentials
15 |
--------------------------------------------------------------------------------
/fixtures/notifications/check-label-match-query.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Notification
3 | metadata:
4 | name: health-check-alerts
5 | spec:
6 | events:
7 | - check.failed
8 | filter: matchQuery(.check, "type!=http labels.Expected-Fail!=true")
9 | to:
10 | email: alerts@acme.com
11 |
--------------------------------------------------------------------------------
/fixtures/notifications/component-match-query.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Notification
3 | metadata:
4 | name: component-alerts
5 | spec:
6 | events:
7 | - component.unhealthy
8 | - component.warning
9 | filter: matchQuery(.component, "type=Workload properties.os=linux labels.owner=data-team labels.environment=production")
10 | to:
11 | email: data-alerts@acme.com
12 |
--------------------------------------------------------------------------------
/fixtures/notifications/component-status.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Notification
3 | metadata:
4 | name: component-error-or-warning
5 | spec:
6 | events:
7 | - component.status.error
8 | - component.status.warning
9 | title: |
10 | {{.component.name}} status updated to {{.component.status}}
11 | template: |
12 | {{range $k, $v := .component.labels}}
13 | **{{$k}}**: {{$v}}
14 | {{end}}
15 |
16 | [Reference]({{.permalink}})
17 | to:
18 | connection: connection://Slack/incident-notifications
19 | properties:
20 | color: |-
21 | {{if eq .component.status "error"}}bad{{else}}#FFA700{{end}}
22 |
--------------------------------------------------------------------------------
/fixtures/notifications/config-health-match-query.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Notification
3 | metadata:
4 | name: config-alerts
5 | spec:
6 | events:
7 | - config.unhealthy
8 | - config.warning
9 | filter: matchQuery(.config, "type=Kubernetes::Pod,Kubernetes::Deployment name!=postgres tags.cluster=prod")
10 | to:
11 | email: alerts@acme.com
--------------------------------------------------------------------------------
/fixtures/notifications/config-health.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Notification
3 | metadata:
4 | name: config-health
5 | spec:
6 | events:
7 | - config.unhealthy
8 | - config.warning
9 | waitFor: 2m
10 | waitForEvalPeriod: 30s
11 | groupBy:
12 | - label:app
13 | to:
14 | connection: connection://default/slack
15 |
--------------------------------------------------------------------------------
/fixtures/notifications/deployment-with-inhibition.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Notification
3 | metadata:
4 | name: pod-with-outgoing-inhibition
5 | spec:
6 | events:
7 | - config.unhealthy
8 | - config.warning
9 | to:
10 | connection: connection://mission-control/slack
11 | inhibitions:
12 | - direction: incoming
13 | from: Kubernetes::Pod
14 | to:
15 | - Kubernetes::Deployment
16 | - Kubernetes::ReplicaSet
17 | depth: 2
--------------------------------------------------------------------------------
/fixtures/notifications/health-check.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Notification
3 | metadata:
4 | name: http-check-passed
5 | spec:
6 | events:
7 | - check.passed
8 | filter: check.type == 'http'
9 | to:
10 | team: backend
11 |
--------------------------------------------------------------------------------
/fixtures/notifications/health-playbook.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Notification
4 | metadata:
5 | name: config-healths
6 | spec:
7 | events:
8 | - config.healthy
9 | - config.unhealthy
10 | - config.warning
11 | to:
12 | playbook: mc/diagnose-configs
13 | fallback:
14 | connection: connection://mc/slack
15 |
--------------------------------------------------------------------------------
/fixtures/notifications/incidents.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Notification
3 | metadata:
4 | name: incident-status-updates
5 | spec:
6 | events:
7 | - incident.status.cancelled
8 | - incident.status.closed
9 | - incident.status.investigating
10 | - incident.status.mitigated
11 | - incident.status.open
12 | - incident.status.resolved
13 | filter: incident.severity == 'High' || incident.severity == 'Critical'
14 | title: |
15 | Incident "{{incident.title}}" status was updated to {{incident.status}}
16 | template: |
17 | Description: {{.incident.description}}
18 | Has communicator: {{if .incident.communicator_id}}Yes{{else}}No{{end}}
19 |
20 | [Reference]({{.permalink}})
21 | to:
22 | person: aditya@flanksource.com
--------------------------------------------------------------------------------
/fixtures/notifications/kube-cronjob-failing.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Notification
3 | metadata:
4 | name: cronjob-alerts
5 | namespace: default
6 | spec:
7 | events:
8 | - config.unhealthy
9 | filter: config.type == 'Kubernetes::CronJob'
10 | to:
11 | email: alerts@acme.com
12 |
--------------------------------------------------------------------------------
/fixtures/notifications/kube-deployment-unhealthy.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Notification
3 | metadata:
4 | name: deployment-health-alerts
5 | spec:
6 | events:
7 | - config.unhealthy
8 | - config.warning
9 | filter: config.type == 'Kubernetes::Deployment'
10 | to:
11 | connection: connection://mission-control/slack
12 |
--------------------------------------------------------------------------------
/fixtures/notifications/kube-pod-crashlooping.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Notification
3 | metadata:
4 | name: podcrashlooping-alerts
5 | namespace: default
6 | spec:
7 | events:
8 | - config.unhealthy
9 | filter: config.type == 'Kubernetes::Pod' && config.status == 'CrashLoopBackOff'
10 | title: "Pod {{.config.name}} in namespace {{.config.tags.namespace}} is in CrashLoopBackOff"
11 | template: |
12 | {{.config.tags.namespace}}/{{.config.name}}
13 | ## Reason
14 | {{.config.config | jq '.status.containerStatuses[0].state.waiting.message' }}
15 |
16 | ### Labels:
17 | {{range $k, $v := .config.config.metadata.labels}}
18 | **{{$k}}**: {{$v}}
19 | {{end}}
20 | to:
21 | email: alerts@acme.com
22 |
--------------------------------------------------------------------------------
/fixtures/permissions/agent-based-permission.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | # yaml-language-server: $schema=https://raw.githubusercontent.com/flanksource/mission-control/refs/heads/main/config/schemas/permission.schema.json
3 | apiVersion: mission-control.flanksource.com/v1
4 | kind: Permission
5 | metadata:
6 | name: demo-agent-access-to-john
7 | spec:
8 | description: allow user john access to all resources push by demo agent
9 | subject:
10 | person: john@doe.com
11 | actions:
12 | - read
13 | object: {}
14 | agents:
15 | - 019449d5-71bd-de63-a191-c23e77b07819 # id of the demo agent
16 |
--------------------------------------------------------------------------------
/fixtures/permissions/allow-person-playbook.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | # yaml-language-server: $schema=../../config/schemas/permission.schema.json
3 | apiVersion: mission-control.flanksource.com/v1
4 | kind: Permission
5 | metadata:
6 | name: allow-user-playbook-run
7 | spec:
8 | description: |
9 | allow user john to run any playbook but only on configs in `mission-control` namespace
10 | subject:
11 | person: john@doe.com
12 | actions:
13 | - playbook:*
14 | object:
15 | playbooks:
16 | - name: "*" # this is a wildcard selector that matches any playbook
17 | configs:
18 | - namespace: mission-control
19 |
--------------------------------------------------------------------------------
/fixtures/permissions/config-notification-group-playbook-permission.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | # yaml-language-server: $schema=../../config/schemas/permissiongroup.schema.json
3 | apiVersion: mission-control.flanksource.com/v1
4 | kind: PermissionGroup
5 | metadata:
6 | name: config-notifications
7 | spec:
8 | name: config-notifications
9 | notifications:
10 | - name: check-alerts
11 | namespace: mc
12 | - name: homelab-config-health-alerts
13 | namespace: mc
14 | ---
15 | # yaml-language-server: $schema=../../config/schemas/permission.schema.json
16 | apiVersion: mission-control.flanksource.com/v1
17 | kind: Permission
18 | metadata:
19 | name: allow-config-notifications-to-run-playbook
20 | spec:
21 | description: allow config notifications to run playbook
22 | subject:
23 | group: config-notifications
24 | actions:
25 | - playbook:run
26 | - playbook:approve
27 | object:
28 | playbooks:
29 | - name: echo-config
30 | configs:
31 | - name: '*'
32 | ---
33 | # yaml-language-server: $schema=../../config/schemas/permission.schema.json
34 | apiVersion: mission-control.flanksource.com/v1
35 | kind: Permission
36 | metadata:
37 | name: allow-config-notifications-to-read-configs
38 | spec:
39 | description: allow config notifications to read configs
40 | subject:
41 | group: config-notifications
42 | actions:
43 | - read
44 | object:
45 | configs:
46 | - name: '*'
--------------------------------------------------------------------------------
/fixtures/permissions/connection-read.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Permission
4 | metadata:
5 | name: john-connection-read
6 | spec:
7 | description: allow john to read all connections
8 | subject:
9 | user: john
10 | actions:
11 | - read
12 | object:
13 | connections:
14 | - name: "*"
--------------------------------------------------------------------------------
/fixtures/permissions/deny-person-playbook.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | # yaml-language-server: $schema=../../config/schemas/permission.schema.json
3 | apiVersion: mission-control.flanksource.com/v1
4 | kind: Permission
5 | metadata:
6 | name: deny-user-foo-playbook-run
7 | spec:
8 | description: deny user foo from running
9 | subject:
10 | person: foo@bar.com
11 | actions:
12 | - playbook:*
13 | deny: true
14 | object:
15 | playbooks:
16 | - name: "*" # this is a wildcard selector that matches any playbook
17 |
--------------------------------------------------------------------------------
/fixtures/permissions/notification-playbook-permission.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | # yaml-language-server: $schema=../../config/schemas/permission.schema.json
3 | apiVersion: mission-control.flanksource.com/v1
4 | kind: Permission
5 | metadata:
6 | name: allow-check-notification-playbook-run
7 | spec:
8 | description: allow check notification to run playbook
9 | subject:
10 | notification: mc/check-alerts
11 | actions:
12 | - playbook:run
13 | - playbook:approve
14 | object:
15 | playbooks:
16 | - name: echo-config
17 |
--------------------------------------------------------------------------------
/fixtures/permissions/playbook-connection.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: PermissionGroup
4 | metadata:
5 | name: all-playbooks
6 | spec:
7 | playbooks:
8 | - name: "*"
9 | ---
10 | apiVersion: mission-control.flanksource.com/v1
11 | kind: Permission
12 | metadata:
13 | name: playbook-connection
14 | spec:
15 | description: allow group all-playbooks access to read all connections
16 | subject:
17 | group: all-playbooks
18 | actions:
19 | - read
20 | object:
21 | connections:
22 | - name: "*"
--------------------------------------------------------------------------------
/fixtures/permissions/rbac-catalog-read.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Permission
4 | metadata:
5 | name: rbac-john-catalog-read
6 | spec:
7 | description: Grant John read permissions to catalogs
8 | subject:
9 | person: john@doe.com
10 | actions:
11 | - read
12 | object:
13 | configs:
14 | - name: "*"
15 |
--------------------------------------------------------------------------------
/fixtures/permissions/scraper-connection.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: PermissionGroup
4 | metadata:
5 | name: all-scrapers
6 | spec:
7 | scrapers:
8 | - name: "*"
9 | ---
10 | apiVersion: mission-control.flanksource.com/v1
11 | kind: Permission
12 | metadata:
13 | name: scraper-connection
14 | spec:
15 | description: allow group all-scrapers access to read all connections
16 | subject:
17 | group: all-scrapers
18 | actions:
19 | - read
20 | object:
21 | connections:
22 | - name: "*"
--------------------------------------------------------------------------------
/fixtures/permissions/system.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: PermissionGroup
4 | metadata:
5 | name: system
6 | spec:
7 | canaries:
8 | - name: "*"
9 | scrapers:
10 | - name: "*"
11 | playbooks:
12 | - name: "*"
13 | topologies:
14 | - name: "*"
15 | notifications:
16 | - name: "*"
17 | ---
18 | apiVersion: mission-control.flanksource.com/v1
19 | kind: Permission
20 | metadata:
21 | name: system-connections-read
22 | spec:
23 | description: allow all mission control services access to read all the connections
24 | subject:
25 | group: system
26 | actions:
27 | - read
28 | object:
29 | connections:
30 | - name: "*"
--------------------------------------------------------------------------------
/fixtures/permissions/tag-based-permission.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | # yaml-language-server: $schema=https://raw.githubusercontent.com/flanksource/mission-control/refs/heads/main/config/schemas/permission.schema.json
3 | apiVersion: mission-control.flanksource.com/v1
4 | kind: Permission
5 | metadata:
6 | name: demo-cluster-access-to-john
7 | spec:
8 | description: allow user john access to all resources in demo cluster
9 | subject:
10 | person: john@doe.com
11 | actions:
12 | - read
13 | object: {}
14 | tags:
15 | cluster: demo
16 |
--------------------------------------------------------------------------------
/fixtures/permissions/topology-connection.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: PermissionGroup
4 | metadata:
5 | name: all-topologies
6 | spec:
7 | topologies:
8 | - name: "*"
9 | ---
10 | apiVersion: mission-control.flanksource.com/v1
11 | kind: Permission
12 | metadata:
13 | name: topology-connection
14 | spec:
15 | description: allow group all-topologies access to read all connections
16 | subject:
17 | group: all-topologies
18 | actions:
19 | - read
20 | object:
21 | connections:
22 | - name: "*"
--------------------------------------------------------------------------------
/fixtures/playbooks/action-result.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: use-previous-action-result
5 | spec:
6 | description: Creates a file with the content of the config
7 | configs:
8 | - types:
9 | - Kubernetes::Pod
10 | actions:
11 | - name: Fetch all changes
12 | sql:
13 | query: SELECT id FROM config_changes WHERE config_id = '{{.config.id}}'
14 | driver: postgres
15 | connection: connection://postgres/local
16 | - name: Send notification
17 | if: 'last_result().count > 0'
18 | notification:
19 | title: 'Changes summary for {{.config.name}}'
20 | connection: connection://slack/flanksource
21 | message: |
22 | {{$rows:=index last_result "count"}}
23 | Found {{$rows}} changes
24 |
--------------------------------------------------------------------------------
/fixtures/playbooks/ai-with-context-from-playbook.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | # yaml-language-server: $schema=../../config/schemas/playbook.schema.json
3 | apiVersion: mission-control.flanksource.com/v1
4 | kind: Playbook
5 | metadata:
6 | name: diagnose-with-context-from-playbook
7 | namespace: mc
8 | spec:
9 | title: Diagnose Pods
10 | description: Use AI to kubernetes pods
11 | category: AI
12 | icon: k8s
13 | configs:
14 | - types:
15 | - Kubernetes::Pod
16 | parameters:
17 | - name: prompt
18 | label: Prompt
19 | default: Find out why {{.config.name}} is unhealthy
20 | properties:
21 | multiline: 'true'
22 | actions:
23 | - name: query
24 | ai:
25 | backend: gemini
26 | connection: connection://mc/gemini
27 | systemPrompt: |
28 | You are a seasoned Kubernetes engineer with extensive expertise in troubleshooting and optimizing Kubernetes resources.
29 | Your primary objective is to assist users in diagnosing issues with Kubernetes pods that are not performing as expected.
30 |
31 | Begin by gathering relevant information and context about the pod in question.
32 | Analyze the logs and pod status to identify any anomalies or misconfigurations that could be contributing to the problem.
33 | Provide a step-by-step breakdown of your diagnostic process, highlighting key findings and potential root causes.
34 | Offer clear and actionable recommendations to resolve the issues, ensuring that your guidance is both comprehensive and easy to understand.
35 | Maintain a professional and supportive tone throughout your response, empowering the user to effectively address and prevent similar issues in the future.
36 | prompt: '{{.params.prompt}}'
37 | playbooks:
38 | - namespace: mc
39 | name: kubernetes-logs
40 | params:
41 | since: 1h
42 | - namespace: mc
43 | name: kubernetes-node-status
44 |
--------------------------------------------------------------------------------
/fixtures/playbooks/azure-devops.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: invoke-azure-devops-pipelines
6 | namespace: default
7 | spec:
8 | parameters:
9 | - name: project
10 | label: Project name
11 | default: Demo1
12 | - name: pipeline
13 | label: Pipeline ID
14 | actions:
15 | - name: Invoke pipeline
16 | azureDevopsPipeline:
17 | org: flanksource
18 | project: '{{.params.project}}'
19 | token:
20 | valueFrom:
21 | secretKeyRef:
22 | name: azure-devops
23 | key: token
24 | pipeline:
25 | id: '{{.params.pipeline}}'
26 |
--------------------------------------------------------------------------------
/fixtures/playbooks/catalog_traversal.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: test-catalog-traverse
6 | spec:
7 | category: Test
8 | icon: namespace
9 | title: Find GitSource
10 | parameters:
11 | - name: kustomization
12 | label: Kustomization
13 | description: Which kustomization to add the new namespace into
14 | required: true
15 | type: config
16 | properties:
17 | filter:
18 | - types:
19 | - Kubernetes::Kustomization
20 |
21 | env:
22 | - name: path
23 | value: |
24 | # gotemplate: left-delim=$[[ right-delim=]]
25 | $[[ .params.kustomization.config | json | jq ".spec.path" ]]
26 | - name: git_url
27 | value: |
28 | # gotemplate: left-delim=$[[ right-delim=]]
29 | $[[ (index (catalog_traverse .params.kustomization.id "Kubernetes::GitRepository") 0).Config | json | jq ".spec.url" ]]
30 | - name: git_branch
31 | value: |
32 | # gotemplate: left-delim=$[[ right-delim=]]
33 | $[[ (index (catalog_traverse .params.kustomization.id "Kubernetes::GitRepository" "incoming") 0).Config | json | jq ".spec.ref.branch" ]]
34 |
35 |
36 | actions:
37 | - name: echo
38 | exec:
39 | script: |
40 | echo "$(.params.kustomization.name) is in $(.env.git_url)@$(.env.git_branch)/$(.env.path)"
41 |
--------------------------------------------------------------------------------
/fixtures/playbooks/conditions-fail.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: conditions-fail
5 | spec:
6 | description: A playbook with an action that always fails
7 | parameters:
8 | - name: delay
9 | actions:
10 | - name: start
11 | exec:
12 | script: date2
13 | - name: Failure
14 | if: "failure()"
15 | exec:
16 | script: echo "Failed"
17 | - name: Always
18 | if: "always()"
19 | exec:
20 | script: echo "Always"
21 | - name: Success
22 | if: "success()"
23 | exec:
24 | script: echo "success"
25 | - name: Delay
26 | if: "success()"
27 | delay: "params.delay"
28 | exec:
29 | script: date
30 |
--------------------------------------------------------------------------------
/fixtures/playbooks/connection-from-scraper.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: kubernetes-connection-from-scraper
6 | namespace: mc
7 | spec:
8 | configs:
9 | - types:
10 | - Kubernetes::Deployment
11 | actions:
12 | - exec:
13 | script: "kubectl get deployments"
14 | connections:
15 | fromConfigItem: "{{.config.id}}"
16 | name: list
17 | category: Echoer
18 | description: Lists all deployments
19 |
--------------------------------------------------------------------------------
/fixtures/playbooks/delayed-exec.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: delayed-exec
5 | spec:
6 | description: Creates a file with the content of the config
7 | parameters:
8 | - name: delay
9 | default: "0"
10 | actions:
11 | - name: start
12 | exec:
13 | script: date
14 | - name: Failure
15 | if: "failure()"
16 | exec:
17 | script: echo "Failed"
18 | - name: Always
19 | if: "always()"
20 | exec:
21 | script: echo "Always"
22 | - name: Success
23 | if: "success()"
24 | exec:
25 | script: echo "success"
26 | - name: Delay
27 | if: 'params.delay != "0" && success()'
28 | delay: "params.delay"
29 | exec:
30 | script: date
31 |
--------------------------------------------------------------------------------
/fixtures/playbooks/delayed-option.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: delayed-option
5 | spec:
6 | parameters:
7 | - name: delay
8 | default: "0"
9 | actions:
10 | - name: start
11 | exec:
12 | script: date
13 |
14 | - name: Delay
15 | if: 'success() && params.delay != "0"'
16 | delay: "params.delay"
17 | exec:
18 | script: date
19 |
--------------------------------------------------------------------------------
/fixtures/playbooks/delete-pv.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: delete-pv
5 | spec:
6 | description: Delete Persistent Volume
7 | configs:
8 | - types:
9 | - Kubernetes::PersistentVolume
10 | approval:
11 | type: any
12 | approvers:
13 | teams:
14 | - DevOps
15 | actions:
16 | - name: kubectl delete pv
17 | exec:
18 | script: kubectl delete persistentvolume {{.config.name}}
19 |
--------------------------------------------------------------------------------
/fixtures/playbooks/deleting-configmap.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: delete-kubernetes-configmap
5 | spec:
6 | description: Delete Kubernetes ConfigMap
7 | configs:
8 | - types:
9 | - Kubernetes::ConfigMap
10 | actions:
11 | - name: 'Delete ConfigMap'
12 | exec:
13 | script: kubectl delete configmap {{.config.name}} --namespace={{.config.tags.namespace}}
14 |
--------------------------------------------------------------------------------
/fixtures/playbooks/ec2.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: ec2-restart
5 | spec:
6 | description: Unconventional EC2 restart
7 | configs:
8 | - types:
9 | - EC2 Instance
10 | labelSelector: "telemetry=enabled"
11 | actions:
12 | - name: 'Stop EC2 instance'
13 | exec:
14 | script: aws ec2 stop-instance --instance-id {{.config.instanceId}}
15 | - name: 'Start again'
16 | exec:
17 | script: aws ec2 start-instance --instance-id {{.config.instanceId}}
18 |
--------------------------------------------------------------------------------
/fixtures/playbooks/env-secrets.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: call-secret-endpoint
6 | namespace: default
7 | spec:
8 | description: Makes an HTTP request to a secret endpoint behind auth.
9 | env:
10 | - name: auth_token
11 | valueFrom:
12 | secretKeyRef:
13 | name: secret-website
14 | key: JWT
15 | actions:
16 | - name: Query localhost
17 | exec:
18 | script: |
19 | curl -H "Authorization: Bearer {{.env.auth_token}}" http://localhost
20 |
--------------------------------------------------------------------------------
/fixtures/playbooks/exec-artifact.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: exec-artifact
5 | spec:
6 | description: Simple script to generate an artifact
7 | configs:
8 | - types:
9 | - EC2 Instance
10 | labelSelector: "telemetry=enabled"
11 | actions:
12 | - name: 'Generate artifact'
13 | exec:
14 | script: echo "hello world" > /tmp/output.txt
15 | artifacts:
16 | - path: /tmp/output.txt
17 |
18 |
--------------------------------------------------------------------------------
/fixtures/playbooks/exec-checkout.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: read-git-repository
5 | spec:
6 | description: Clones the git repository and reads the first line of the file
7 | configs:
8 | - types:
9 | - AWS::EKS::Cluster
10 | actions:
11 | - name: Clone and read go.sum
12 | exec:
13 | script: head -n 1 $READ_FILE
14 | env:
15 | - name: READ_FILE
16 | value: go.sum
17 | checkout:
18 | url: https://github.com/flanksource/artifacts
19 | connection: connection://github/aditya-all-access
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/fixtures/playbooks/exec-default-parameter.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: notify-send
6 | spec:
7 | parameters:
8 | - name: message
9 | label: The message for notification
10 | default: '{{.config.name}}'
11 | actions:
12 | - name: Modify repo
13 | exec:
14 | script: notify-send {{.params.message}}
15 |
--------------------------------------------------------------------------------
/fixtures/playbooks/exec-delayed-role-binding.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: test-cluster-role-binding
5 | spec:
6 | actions:
7 | - name: Create new service account and test get pods
8 | exec:
9 | script: |
10 | kubectl create serviceaccount demo-playbook-sa
11 | response=$(kubectl auth can-i get pods --as=system:serviceaccount:default:demo-playbook-sa) # Save to a response because exitcode=1
12 | echo $response
13 | - name: Create new Cluster role binding
14 | exec:
15 | script: |
16 | kubectl create clusterrole demo-playbook-clusterrole --verb=get --resource=pods
17 | kubectl create clusterrolebinding demo-playbook-cluster-role-binding --clusterrole=demo-playbook-clusterrole --serviceaccount=default:demo-playbook-sa
18 | kubectl auth can-i get pods --as=system:serviceaccount:default:demo-playbook-sa
19 | - name: Cleanup
20 | delay: 5s
21 | exec:
22 | script: |
23 | kubectl delete clusterrolebinding demo-playbook-cluster-role-binding
24 | kubectl delete serviceaccount demo-playbook-sa
25 | kubectl delete clusterrole demo-playbook-clusterrole
26 |
--------------------------------------------------------------------------------
/fixtures/playbooks/exec-filter.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: notify-send-with-filter
6 | spec:
7 | parameters:
8 | - name: message
9 | label: The message for notification
10 | default: '{{.config.name}}'
11 | configs:
12 | - types:
13 | - Kubernetes::Pod
14 | actions:
15 | - name: Send notification
16 | exec:
17 | script: notify-send "{{.config.name}} was created"
18 | - name: Bad script
19 | exec:
20 | script: deltaforce
21 | - name: Send all success notification
22 | if: success() # this filter practically skips this action as the second action above always fails
23 | exec:
24 | script: notify-send "Everything went successfully"
25 | - name: Send notification regardless
26 | if: always()
27 | exec:
28 | script: notify-send "a Pod config was created"
29 |
--------------------------------------------------------------------------------
/fixtures/playbooks/exec-kubectl-logs-artifacts.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: archive-pod-logs
5 | spec:
6 | description: Grab the latest 100 lines of etcd logs
7 | components:
8 | - types:
9 | - KubernetesPod
10 | labelSelector: "component=etcd"
11 | actions:
12 | - name: 'etcd logs'
13 | exec:
14 | script: |
15 | mkdir -p /tmp/kubectl-logs
16 | kubectl logs --tail=100 -n kube-system etcd-kind-control-plane > /tmp/kubectl-logs/etcd-kind-control-plane
17 | cat /tmp/kubectl-logs/etcd-kind-control-plane # cat so we see them in the playbooks action logs
18 | artifacts:
19 | - path: /tmp/kubectl-logs/*
20 |
--------------------------------------------------------------------------------
/fixtures/playbooks/exec-powershell.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: exec-powershell
6 | annotations:
7 | trace: "true"
8 | spec:
9 | configs:
10 | - types:
11 | - Kubernetes::Pod
12 | actions:
13 | - name: Powershell
14 | exec:
15 | script: |
16 | #! pwsh
17 | $env:NO_COLOR = $true
18 | @{item= "{{.config.name}}"} | ConvertTo-JSON
19 | - name: delims
20 | exec:
21 | script: |
22 | #! pwsh
23 | # gotemplate: left-delim=$[[ right-delim=]]
24 | $message = "$[[.config.name]]"
25 | Write-Host "{{ $message }}"
26 | Write-Host @{ Number = 1; Shape = "Square"; Color = "Blue"} | ConvertTo-JSON
27 |
--------------------------------------------------------------------------------
/fixtures/playbooks/github.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | # Example workflow here: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#providing-inputs
3 | apiVersion: mission-control.flanksource.com/v1
4 | kind: Playbook
5 | metadata:
6 | name: invoke-release-action
7 | namespace: default
8 | spec:
9 | parameters:
10 | - name: repo
11 | label: The repository name
12 | default: duty
13 | - name: branch
14 | label: Branch to run the workflow on
15 | default: main
16 | - name: environment
17 | label: Environment to run the release on
18 | default: production
19 | - name: logLevel
20 | label: Log level
21 | type: list
22 | properties:
23 | options:
24 | - label: info
25 | value: info
26 | - label: warning
27 | value: warning
28 | - label: error
29 | value: error
30 | default: warning
31 | - name: tags
32 | label: Should tag or not
33 | type: checkbox
34 | default: 'false'
35 | actions:
36 | - name: Invoke github workflow
37 | github:
38 | username: flanksource
39 | repo: '{{.params.repo}}'
40 | token:
41 | valueFrom:
42 | secretKeyRef:
43 | name: github
44 | key: token
45 | workflows:
46 | - id: release.yaml
47 | ref: '{{.params.branch}}'
48 | input: |
49 | {
50 | "environment": "{{.params.environment}}",
51 | "logLevel": "{{.params.logLevel}}",
52 | "tags": "{{.params.tags}}"
53 | }
54 |
--------------------------------------------------------------------------------
/fixtures/playbooks/gitops.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: update-flanksource-test
6 | spec:
7 | parameters:
8 | - name: namespace
9 | label: The namespace
10 | actions:
11 | - name: Modify repo
12 | gitops:
13 | repo:
14 | url: https://github.com/adityathebe/flanksource-test
15 | connection: connection://github/adityathebe
16 | base: master
17 | branch: playbooks-branch-{{.params.namespace}}
18 | commit:
19 | email: '{{.user.email}}'
20 | author: '{{.user.name}}'
21 | message: |
22 | Testing commit message from playbooks {{.params.namespace}}
23 | pr:
24 | title: New PR for namespace {{.params.namespace}}
25 | tags:
26 | - abc
27 | - efg
28 | patches:
29 | - path: fixtures/minimal/dns_pass.yaml
30 | yq: '.metadata.namespace = "{{.params.namespace}}"'
31 | files:
32 | - path: fixtures/topology/single-check.yaml
33 | content: $delete
34 | - path: '{{.params.namespace}}-ns.yaml'
35 | content: |
36 | apiVersion: v1
37 | kind: Namespace
38 | metadata:
39 | name: {{.params.namespace}}
40 |
--------------------------------------------------------------------------------
/fixtures/playbooks/http-secret-parameter.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: cloud-storage-access-issue-alert
5 | namespace: mc
6 | spec:
7 | description: Notify the relevant team when there is an issue accessing cloud storage, ensuring swift action to mitigate any potential impact on services.
8 | parameters:
9 | - name: issueDetails
10 | label: Details of the Issue
11 | type: secret
12 | actions:
13 | - name: send-alert
14 | http:
15 | url: https://webhook.site/4497113a-2d88-490d-ab91-c3c19bf035d7
16 | method: POST
17 | headers:
18 | - name: Content-Type
19 | value: application/json
20 | templateBody: true
21 | body: |
22 | {
23 | "alert": "Cloud Storage Access Issue",
24 | "details": "$(params.issueDetails)"
25 | }
26 |
--------------------------------------------------------------------------------
/fixtures/playbooks/http.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: log-component-status
5 | spec:
6 | description: Post component name and status to webhook
7 | components:
8 | - types:
9 | - KubernetesCluster
10 | actions:
11 | - name: Post a message to webhook
12 | http:
13 | url: https://webhook.site/9f1392a6-718a-4ef5-a8e2-bfb55b08afca
14 | method: POST
15 | body: |
16 | {
17 | "component": {
18 | "name": "{{.component.name}}",
19 | "status": "{{.component.status}}"
20 | }
21 | }
22 | templateBody: true
23 | headers:
24 | - name: X-Postgres-User
25 | value: admin@local
26 | - name: X-Flanksource-Token
27 | value: secret123
28 |
--------------------------------------------------------------------------------
/fixtures/playbooks/logs/cloudwatch.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: cloudwatch-events
5 | namespace: mc
6 | spec:
7 | title: Cloudwatch Events
8 | icon: cloudwatch
9 | category: Logs
10 | description: Fetch events from Cloudwatch
11 | parameters:
12 | - name: logGroup
13 | label: Log Group
14 | description: The log group to fetch events from
15 | required: true
16 | - name: limit
17 | label: Limit
18 | description: The maximum number of events to fetch
19 | required: false
20 | default: '100'
21 | configs:
22 | - types:
23 | - AWS::::Account
24 | actions:
25 | - name: Fetch events from CloudWatch
26 | logs:
27 | cloudwatch:
28 | start: now-24h
29 | limit: $(.params.limit)
30 | logGroup: $(.params.logGroup)
31 | query: |
32 | fields @message, @timestamp, @logStream, @log
33 | | sort @timestamp desc
34 | | limit $(.params.limit)
35 |
--------------------------------------------------------------------------------
/fixtures/playbooks/logs/k8s.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: kubernetes-logs
5 | namespace: mc
6 | spec:
7 | title: Kubernetes Logs
8 | icon: logs
9 | category: Logs
10 | description: Fetch logs from Kubernetes
11 | configs:
12 | - types:
13 | - Kubernetes::Pod
14 | - Kubernetes::Deployment
15 | - Kubernetes::StatefulSet
16 | - Kubernetes::DaemonSet
17 | parameters:
18 | - name: limit
19 | label: Limit
20 | description: The maximum number of logs to fetch
21 | required: false
22 | default: "100"
23 | actions:
24 | - name: Fetch logs from Loki
25 | logs:
26 | kubernetes:
27 | kind: $(.config.config_class)
28 | apiVersion: $(.config.config.apiVersion)
29 | namespace: $(.config.tags.namespace)
30 | name: $(.config.name)
31 | limit: $(.params.limit)
32 | start: now-2h
33 |
--------------------------------------------------------------------------------
/fixtures/playbooks/logs/loki.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: loki-logs
5 | namespace: mc
6 | spec:
7 | title: Loki Logs
8 | icon: logs
9 | category: Logs
10 | description: Fetch logs from Loki
11 | configs:
12 | - types:
13 | - Kubernetes::Pod
14 | - Kubernetes::Deployment
15 | parameters:
16 | - name: limit
17 | label: Limit
18 | description: The maximum number of logs to fetch
19 | required: false
20 | default: "100"
21 | actions:
22 | - name: Fetch logs from Loki
23 | logs:
24 | loki:
25 | url: https://logs-prod-111.grafana.net
26 | username:
27 | valueFrom:
28 | secretKeyRef:
29 | name: loki-grafana-cloud
30 | key: userid
31 | password:
32 | valueFrom:
33 | secretKeyRef:
34 | name: loki-grafana-cloud
35 | key: password
36 | query: |
37 | {namespace="{{ .config.tags.namespace }}",{{.config.config_class | toLower}}="{{ .config.name }}"}
38 | limit: $(.params.limit)
39 | start: now-2h
40 | match:
41 | - severity != "info" && severity != "unknown"
42 | - labels.service == 'payment'
43 | dedupe:
44 | window: 1h
45 | fields:
46 | - message
47 |
--------------------------------------------------------------------------------
/fixtures/playbooks/logs/opensearch.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: opensearch-logs
5 | namespace: mc
6 | spec:
7 | title: OpenSearch Logs
8 | icon: elasticsearch
9 | category: Logs
10 | description: Fetch logs from OpenSearch
11 | configs:
12 | - types:
13 | - Kubernetes::Pod
14 | - Kubernetes::Deployment
15 | parameters:
16 | - name: limit
17 | label: Limit
18 | description: The maximum number of logs to fetch
19 | required: false
20 | default: '100'
21 | actions:
22 | - name: Fetch logs from OpenSearch
23 | logs:
24 | opensearch:
25 | address: http://localhost:9200
26 | query: |
27 | {
28 | "query": {
29 | "bool": {
30 | "filter": [
31 | { "term": { "kubernetes.namespace": "$(.config.tags.namespace)" } },
32 | { "term": { "kubernetes.labels.app": "$(.config.name)" } }
33 | ]
34 | }
35 | }
36 | }
37 | index: k8s-logs
38 | limit: $(.params.limit)
39 |
--------------------------------------------------------------------------------
/fixtures/playbooks/notification-playbook.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: notify-file-creation
5 | spec:
6 | description: Sends Telegram notification when a file is created
7 | parameters:
8 | - name: path
9 | label: path of the file
10 | actions:
11 | - name: 'Create the file'
12 | exec:
13 | script: touch {{.params.path}}
14 | - name: 'Send notification'
15 | notification:
16 | connection: connection://telegram/aditya
17 | title: 'File {{.params.path}} created successfully'
18 | message: 'File "{{.params.path}}" created successfully'
19 |
--------------------------------------------------------------------------------
/fixtures/playbooks/notify-database-component-fail.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: notify-unhealthy-database-component
5 | spec:
6 | description: Sends desktop notification when any database component becomes unhealthy
7 | 'on':
8 | component:
9 | - event: unhealthy
10 | filter: component.type == 'database'
11 | labels:
12 | industry: e-commerce
13 | actions:
14 | - name: 'Send desktop notification'
15 | exec:
16 | script: notify-send --urgency=critical 'Component {{.component.name}} has become unhealthy!!'
17 |
--------------------------------------------------------------------------------
/fixtures/playbooks/params.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: params-sink
5 | namespace: mission-control
6 | spec:
7 | title: Params Kitchen Sink
8 | actions:
9 | - name: echo
10 | exec:
11 | script: echo {{. | toJSON | shellQuote}} | jq
12 | configs:
13 | - types:
14 | - Kubernetes::Namespace
15 | description: Test playbook
16 | category: Kitchen Sink
17 | parameters:
18 | - label: Text Input (Default)
19 | name: text-input
20 | type: text
21 | default: "hello world"
22 | - label: Checkbox
23 | name: checkbox
24 | type: checkbox
25 | - label: Teams Selector
26 | name: teams
27 | type: team
28 | - label: People Selector
29 | name: people
30 | properties:
31 | role: admin
32 | type: people
33 | - label: Component Selector
34 | name: component
35 | properties:
36 | filter:
37 | - types:
38 | - KubernetesPod
39 | type: component
40 | - label: Configs Selector
41 | name: configs
42 | properties:
43 | filter:
44 | - types:
45 | - Kubernetes::Pod
46 | type: config
47 | - label: Code Editor (YAML)
48 | name: code-editor-yaml
49 | properties:
50 | language: yaml
51 | type: code
52 | - label: Code Editor (JSON)
53 | name: code-editor-json
54 | properties:
55 | language: json
56 | type: code
57 | - label: Textarea
58 | name: textarea
59 | properties:
60 | multiline: "true"
61 | type: text
62 | - label: List
63 | name: list
64 | properties:
65 | options:
66 | - label: Option 1
67 | value: option-1
68 | - label: Option 2
69 | value: option-2
70 | - label: Option 3
71 | value: option-3
72 | type: list
73 | - label: secretKey
74 | name: secretKey
75 | type: secret
76 |
--------------------------------------------------------------------------------
/fixtures/playbooks/pod.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: ping-database-from-pod
5 | spec:
6 | description: Create a pod and ping the database
7 | actions:
8 | - name: Ping database
9 | pod:
10 | name: test-pod
11 | spec:
12 | containers:
13 | - name: my-alpine-container
14 | image: ubuntu:jammy
15 | command: ['/bin/sh']
16 | args:
17 | - -c
18 | - 'apt-get update -y && apt-get install -y iputils-ping && ping -c 2 postgres.default.svc.cluster.local'
19 | resources:
20 | limits:
21 | memory: 128Mi
22 | cpu: '1'
23 | requests:
24 | memory: 64Mi
25 | cpu: '0.2'
26 |
--------------------------------------------------------------------------------
/fixtures/playbooks/postgres-backup.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: backup-postgres
6 | spec:
7 | actions:
8 | - name: backup-postgres
9 | exec:
10 | env:
11 | - name: CONN_STRING
12 | valueFrom:
13 | secretKeyRef:
14 | name: flanksource_postgres
15 | key: DB_URL
16 | script: pg_dump --dbname "$CONN_STRING" -F c -b -v -f /mnt/backup/postgres.dump
17 | - name: notify
18 | if: 'failure()'
19 | notification:
20 | title: "Postgres backup failed"
21 | message: |
22 | {
23 | "blocks": [
24 | {{slackSectionTextMD (printf `:rotating_light: *Postgres backup failed*`)}},
25 | {{slackSectionTextMD (printf "*Error:* %s" getLastAction.error)}},
26 | {{slackURLAction "Report" "https://console.flanksource.com/mission-control/playbooks/postgres-backup"}}
27 | ]
28 | }
29 | connection: connection://mc/flanksource-slack
30 |
--------------------------------------------------------------------------------
/fixtures/playbooks/retry.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: backup-postgres-db
5 | namespace: mc
6 | spec:
7 | category: Maintenance
8 | description: Backs up a PostgreSQL database and retries on failure
9 | actions:
10 | - name: backup-database
11 | retry:
12 | limit: 5
13 | jitter: 10 # 10% random jitter
14 | duration: 10s
15 | exponent:
16 | multiplier: 2
17 | exec:
18 | script: |
19 | # Define backup parameters
20 | DB_NAME="my_database"
21 | DB_USER="postgres"
22 | DB_HOST="db.example.com"
23 | BACKUP_DIR="/var/backups"
24 | BACKUP_FILE="$BACKUP_DIR/$DB_NAME-$(date +%F_%H-%M-%S).sql.gz"
25 |
26 | echo "Starting backup of database: $DB_NAME"
27 |
28 | # Create the backup directory if it doesn't exist
29 | mkdir -p "$BACKUP_DIR"
30 |
31 | # Perform the database backup
32 | PGPASSWORD="your_password" pg_dump -h $DB_HOST -U $DB_USER -F c $DB_NAME | gzip > $BACKUP_FILE
33 |
34 | # Verify backup success
35 | if [ $? -ne 0 ]; then
36 | echo "Database backup failed, retrying..."
37 | exit 1
38 | fi
39 |
40 | echo "Backup successful! File saved at $BACKUP_FILE"
41 | exit 0
--------------------------------------------------------------------------------
/fixtures/playbooks/runner.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: delete-namespace
5 | spec:
6 | runsOn:
7 | - local # Central instance
8 | - aws # agent 1
9 | - azure # agent 2
10 | description: Deletes namespace from all the agents and the host
11 | configs:
12 | - types:
13 | - Kubernetes::Namespace
14 | actions:
15 | - name: Delete the namespace on the host
16 | exec:
17 | script: kubectl delete namespace {{.config.name}}
18 | - name: Delete the namespace on the agent aws
19 | runsOn:
20 | - 'aws'
21 | exec:
22 | script: kubectl delete namespace {{.config.name}}
23 | - name: Delete the namespace on the Azure
24 | runsOn:
25 | - 'azure'
26 | exec:
27 | script: kubectl delete namespace {{.config.name}}
28 | - name: Send notification
29 | if: 'success()'
30 | notification:
31 | connection: connection://slack/flanksource
32 | title: Namespace {{.config.name}} deleted successfully
33 | message: Namespace {{.config.name}} deleted successfully
34 | - name: Notify failure
35 | if: 'failure()'
36 | notification:
37 | connection: connection://slack/flanksource
38 | title: Namespace {{.config.name}} deletion failed
39 | message: Namespace {{.config.name}} deletion failed
40 |
--------------------------------------------------------------------------------
/fixtures/playbooks/runs-on-simple.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: write-to-file
6 | namespace: mission-control
7 | spec:
8 | category: testing
9 | runsOn:
10 | - aditya-desktop
11 | description: writes the config on the agent host
12 | configs:
13 | - types:
14 | - Kubernetes::ConfigMap
15 | actions:
16 | - name: write-to-file
17 | exec:
18 | script: echo "{{.config.config}}" > /tmp/{{.config.name}}.txt
19 |
--------------------------------------------------------------------------------
/fixtures/playbooks/scale-deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: scale-deployment
5 | spec:
6 | description: Scale Deployment
7 | configs:
8 | - types:
9 | - Kubernetes::Deployment
10 | parameters:
11 | - name: replicas
12 | label: The new desired number of replicas.
13 | actions:
14 | - name: kubectl scale
15 | exec:
16 | script: |
17 | kubectl scale --replicas={{.params.replicas}} \
18 | --namespace={{.config.tags.namespace}} \
19 | deployment {{.config.name}}
20 |
--------------------------------------------------------------------------------
/fixtures/playbooks/sql.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: count-event-queue
5 | spec:
6 | 'on':
7 | component:
8 | - event: error
9 | filter: component.cost_per_minute > 0.50
10 | labels:
11 | type: database
12 | description: Count the number of events in event queue
13 | components:
14 | - types:
15 | - Database
16 | actions:
17 | - name: Get the total event count
18 | sql:
19 | connection: connection://incident-commander
20 | driver: postgres
21 | query: SELECT COUNT(*) FROM event_queue
22 | # - name: Notify event count
23 | # http:
24 | # url: https://incidents.flanksource.com
25 | # body: |
26 | # {
27 | # "count": {{.result}}
28 | # }
29 |
--------------------------------------------------------------------------------
/fixtures/playbooks/stop-component-ec2-instances.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: stop-expensive-ec2-instance
5 | spec:
6 | 'on':
7 | component:
8 | - event: error
9 | filter: component.cost_per_minute > 0.50
10 | labels:
11 | type: ec2
12 | description: Stop expensive EC2 components
13 | actions:
14 | - name: 'scale deployment'
15 | exec:
16 | script: aws ec2 stop-instances --instance-ids={{.component.name}}
17 |
--------------------------------------------------------------------------------
/fixtures/playbooks/stop-crashloop-pods.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: stop-crashloop-pod
5 | spec:
6 | 'on':
7 | canary:
8 | - event: failed
9 | labels:
10 | alertname: KubePodCrashLoopingcontainer
11 | description: Stop Pods that are on CrashLoop
12 | actions:
13 | - name: 'Stop pod'
14 | exec:
15 | script: kubectl delete pod {{index .check.labels "pod"}}
16 |
--------------------------------------------------------------------------------
/fixtures/playbooks/upgrade-eks-cluster.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: upgrade-eks-cluster
5 | spec:
6 | actions:
7 | - exec:
8 | script: sleep 10
9 | name: Check for Incompatible Objects
10 | - exec:
11 | script: sleep 15
12 | name: Remove cluster from global LB
13 | - exec:
14 | script: |
15 | echo Updating to v1.27.7-eks-a59e1f0
16 | sleep 10
17 | echo Update completed
18 | name: Update EKS Version
19 | - exec:
20 | script: |
21 | echo Draining ip-10-0-4-23.eu-west-1.compute.internal
22 | echo Terminating ip-10-0-4-23.eu-west-1.compute.internal
23 | sleep 10
24 | echo Draining ip-10-0-4-27.eu-west-1.compute.internal
25 | echo Terminating ip-10-0-4-27.eu-west-1.compute.internal
26 | sleep 10
27 | echo Draining ip-10-0-4-33.eu-west-1.compute.internal
28 | echo Terminating ip-10-0-4-33.eu-west-1.compute.internal
29 | name: Roll all Nodes
30 | approval:
31 | approvers:
32 | people:
33 | - admin@local
34 | type: any
35 | configs:
36 | - types:
37 | - AWS::EKS::Cluster
38 | description: Upgrade EKS Cluster
39 | parameters:
40 | - label: The new EKS version
41 | name: version
42 |
--------------------------------------------------------------------------------
/fixtures/playbooks/webhook-trigger.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: create-file-on-webhook
5 | spec:
6 | description: Create a file specified by the webhook
7 | components:
8 | - types:
9 | - KubernetesCluster
10 | 'on':
11 | webhook:
12 | path: my-webhook
13 | authentication:
14 | basic:
15 | username:
16 | value: my-username
17 | password:
18 | value: my-password
19 | parameters:
20 | - name: path
21 | label: Absolute path of the file to create
22 | actions:
23 | - name: Create the file
24 | exec:
25 | script: touch {{.params.path}}
26 |
--------------------------------------------------------------------------------
/fixtures/rules/default.yaml:
--------------------------------------------------------------------------------
1 | kind: IncidentRule
2 | apiVersion: mission-control.flanksource.com/v1
3 | metadata:
4 | name: default
5 | labels:
6 | a: b
7 | c: d
8 | spec:
9 | priority: 1
10 | filter:
11 | status:
12 | - unhealthy
13 | - warning
14 | components:
15 | - types:
16 | - "!virtual"
17 | autoAssignOwner: true
18 |
19 | breakOnMatch: true
20 | autoClose:
21 | timeout: 15m
22 | hoursOfOperation: []
23 |
24 | responders:
25 | email:
26 | - to: dummy@test.com
27 | template:
28 | severity: med
29 | type: cost
30 |
--------------------------------------------------------------------------------
/fixtures/silences/postgresql-sts.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: NotificationSilence
4 | metadata:
5 | name: postgresql-sts
6 | spec:
7 | description: silence notification from all postgresql sts
8 | filter: config.name == "postgresql" && config.type == "Kubernetes::StatefulSet"
9 |
--------------------------------------------------------------------------------
/fixtures/silences/rds.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: NotificationSilence
3 | metadata:
4 | name: aws-rds-readreplica-maintenance
5 | spec:
6 | description: >
7 | Silence planned maintenance and brief healthy/unhealthy flaps
8 | for RDS Postgres instances in flanksource account
9 | filter: >
10 | config.type == "AWS::RDS::DBInstance" &&
11 | config.tags["account-name"] == "flanksource" &&
12 | config.config.Engine == "postgres"
13 |
--------------------------------------------------------------------------------
/fixtures/silences/silence-test-deployments.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: NotificationSilence
4 | metadata:
5 | name: low-severity-jobs
6 | spec:
7 | description: silence notification from all jobs with low severity
8 | selectors:
9 | - types:
10 | - Kubernetes::Job
11 | tagSelector: severity=low
12 |
--------------------------------------------------------------------------------
/fixtures/silences/silence-test-env.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: NotificationSilence
4 | metadata:
5 | name: test-env-silence
6 | spec:
7 | from: "2025-01-01"
8 | until: "2025-02-01"
9 | description: >
10 | Silence notifications from all resources in test and stage namespaces
11 | for the next 30 days
12 | selectors:
13 | - namespace: test
14 | - namespace: stage
15 |
--------------------------------------------------------------------------------
/hack/generate-schemas/.gitignore:
--------------------------------------------------------------------------------
1 | go.sum
2 | go.mod
3 |
--------------------------------------------------------------------------------
/hack/generate-schemas/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "os"
5 | "path"
6 |
7 | "github.com/flanksource/commons/logger"
8 | "github.com/flanksource/duty/schema/openapi"
9 | v1 "github.com/flanksource/incident-commander/api/v1"
10 | "github.com/spf13/cobra"
11 | )
12 |
13 | var schemas = map[string]any{
14 | "connection": &v1.Connection{},
15 | "permission": &v1.Permission{},
16 | "permissiongroup": &v1.PermissionGroup{},
17 | "notification": &v1.Notification{},
18 | "notificationsilence": &v1.NotificationSilence{},
19 | "playbook": &v1.Playbook{},
20 | "playbook-spec": &v1.PlaybookSpec{}, // for go-side validation
21 | "incident-rules": &v1.IncidentRule{},
22 | "application": &v1.Application{},
23 | }
24 |
25 | var generateSchema = &cobra.Command{
26 | Use: "generate-schema",
27 | Run: func(cmd *cobra.Command, args []string) {
28 | _ = os.Mkdir(schemaPath, 0755)
29 | for file, obj := range schemas {
30 | p := path.Join(schemaPath, file+".schema.json")
31 | if err := openapi.WriteSchemaToFile(p, obj); err != nil {
32 | logger.Fatalf("unable to save schema: %v", err)
33 | }
34 | logger.Infof("Saved OpenAPI schema to %s", p)
35 | }
36 | },
37 | }
38 |
39 | var schemaPath string
40 |
41 | func main() {
42 | generateSchema.Flags().StringVar(&schemaPath, "schema-path", "../../config/schemas", "Path to save JSON schema to")
43 | if err := generateSchema.Execute(); err != nil {
44 | os.Exit(1)
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/incidents/jobs.go:
--------------------------------------------------------------------------------
1 | package incidents
2 |
3 | import (
4 | "github.com/flanksource/duty/job"
5 | "github.com/flanksource/incident-commander/incidents/responder"
6 | )
7 |
8 | var IncidentJobs = []*job.Job{
9 | EvaluateEvidence, IncidentRules, responder.SyncComments, responder.SyncConfig,
10 | }
11 |
--------------------------------------------------------------------------------
/incidents/responder/jira/config.go:
--------------------------------------------------------------------------------
1 | package jira
2 |
3 | import (
4 | "github.com/flanksource/duty/context"
5 | "github.com/flanksource/incident-commander/api"
6 | "github.com/pkg/errors"
7 | )
8 |
9 | func (jc *JiraClient) SyncConfig(ctx context.Context, team api.Team) (configType string, configName string, config string, err error) {
10 | config, err = jc.GetConfigJSON()
11 | if err != nil {
12 | return "", "", "", errors.Wrap(err, "error generating config from Jira")
13 | }
14 |
15 | teamSpec, err := team.GetSpec()
16 | if err != nil {
17 | return "", "", "", errors.Wrap(err, "error getting team spec")
18 | }
19 |
20 | configName = teamSpec.ResponderClients.Jira.Values["project"]
21 | configType = ResponderType
22 | return
23 | }
24 |
--------------------------------------------------------------------------------
/incidents/responder/jira/notify.go:
--------------------------------------------------------------------------------
1 | package jira
2 |
3 | import (
4 | "fmt"
5 |
6 | goJira "github.com/andygrunwald/go-jira"
7 | "github.com/flanksource/duty/context"
8 | "github.com/flanksource/incident-commander/api"
9 | "github.com/mitchellh/mapstructure"
10 | )
11 |
12 | func (jc *JiraClient) NotifyResponder(ctx context.Context, responder api.Responder) (string, error) {
13 | if responder.Properties["responderType"] != ResponderType {
14 | return "", fmt.Errorf("invalid responderType: %s", responder.Properties["responderType"])
15 | }
16 |
17 | var issueOptions JiraIssue
18 | err := mapstructure.Decode(responder.Properties, &issueOptions)
19 | if err != nil {
20 | return "", err
21 | }
22 |
23 | var issue *goJira.Issue
24 | if issue, err = jc.CreateIssue(issueOptions); err != nil {
25 | return "", err
26 | }
27 |
28 | return issue.Key, nil
29 | }
30 |
31 | func (jc *JiraClient) NotifyResponderAddComment(ctx context.Context, responder api.Responder, comment string) (string, error) {
32 | if responder.Properties["responderType"] != ResponderType {
33 | return "", fmt.Errorf("invalid responderType: %s", responder.Properties["responderType"])
34 | }
35 |
36 | commentId, err := jc.AddComment(responder.ExternalID, comment)
37 | if err != nil {
38 | return "", err
39 | }
40 |
41 | return commentId, nil
42 | }
43 |
--------------------------------------------------------------------------------
/incidents/responder/msplanner/config.go:
--------------------------------------------------------------------------------
1 | package msplanner
2 |
3 | import (
4 | "github.com/flanksource/duty/context"
5 | "github.com/flanksource/incident-commander/api"
6 | "github.com/pkg/errors"
7 | )
8 |
9 | func (client *MSPlannerClient) SyncConfig(ctx context.Context, team api.Team) (configType string, configName string, config string, err error) {
10 | config, err = client.GetConfigJSON()
11 | if err != nil {
12 | return "", "", "", errors.Wrap(err, "error generating config from MSPlanner")
13 | }
14 | teamSpec, err := team.GetSpec()
15 | if err != nil {
16 | return "", "", "", errors.Wrap(err, "error getting team spec")
17 | }
18 |
19 | configType = ResponderType
20 | configName = teamSpec.ResponderClients.MSPlanner.Values["plan"]
21 | return
22 | }
23 |
--------------------------------------------------------------------------------
/incidents/responder/msplanner/notify.go:
--------------------------------------------------------------------------------
1 | package msplanner
2 |
3 | import (
4 | "github.com/flanksource/duty/context"
5 | "github.com/flanksource/incident-commander/api"
6 | msgraphModels "github.com/microsoftgraph/msgraph-sdk-go/models"
7 | "github.com/mitchellh/mapstructure"
8 | )
9 |
10 | func (client *MSPlannerClient) NotifyResponderAddComment(ctx context.Context, responder api.Responder, comment string) (string, error) {
11 | return client.AddComment(responder.ExternalID, comment)
12 | }
13 |
14 | func (client *MSPlannerClient) NotifyResponder(ctx context.Context, responder api.Responder) (string, error) {
15 | var taskOptions MSPlannerTask
16 | err := mapstructure.Decode(responder.Properties, &taskOptions)
17 | if err != nil {
18 | return "", err
19 | }
20 |
21 | var task msgraphModels.PlannerTaskable
22 | if task, err = client.CreateTask(taskOptions); err != nil {
23 | return "", err
24 | }
25 |
26 | return *task.GetId(), nil
27 | }
28 |
--------------------------------------------------------------------------------
/incidents/suite_test.go:
--------------------------------------------------------------------------------
1 | package incidents
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/flanksource/duty/context"
7 | "github.com/flanksource/duty/tests/setup"
8 | ginkgo "github.com/onsi/ginkgo/v2"
9 | . "github.com/onsi/gomega"
10 | )
11 |
12 | func TestIncidents(t *testing.T) {
13 | RegisterFailHandler(ginkgo.Fail)
14 | ginkgo.RunSpecs(t, "Incident")
15 | }
16 |
17 | var (
18 | DefaultContext context.Context
19 | )
20 |
21 | var _ = ginkgo.BeforeSuite(func() {
22 | DefaultContext = setup.BeforeSuiteFn()
23 |
24 | })
25 | var _ = ginkgo.AfterSuite(setup.AfterSuiteFn)
26 |
--------------------------------------------------------------------------------
/jobs/catalog.go:
--------------------------------------------------------------------------------
1 | package jobs
2 |
3 | import (
4 | "github.com/flanksource/duty/job"
5 | )
6 |
7 | var RefreshConfigItemSummary3dView = &job.Job{
8 | Name: "RefreshConfigItemSummary3dView",
9 | Schedule: "@every 10m",
10 | Retention: job.RetentionFew,
11 | Singleton: true,
12 | JobHistory: true,
13 | RunNow: true,
14 | Fn: func(ctx job.JobRuntime) error {
15 | return job.RefreshConfigItemSummary3d(ctx.Context)
16 | },
17 | }
18 |
19 | var RefreshConfigItemSummary7dView = &job.Job{
20 | Name: "RefreshConfigItemSummary7dView",
21 | Schedule: "@every 30m",
22 | Retention: job.RetentionFew,
23 | Singleton: true,
24 | JobHistory: true,
25 | RunNow: true,
26 | Fn: func(ctx job.JobRuntime) error {
27 | return job.RefreshConfigItemSummary7d(ctx.Context)
28 | },
29 | }
30 |
31 | var RefreshConfigItemSummary30dView = &job.Job{
32 | Name: "RefreshConfigItemSummary30dView",
33 | Schedule: "@every 1h",
34 | Retention: job.RetentionFew,
35 | Singleton: true,
36 | JobHistory: true,
37 | RunNow: true,
38 | Fn: func(ctx job.JobRuntime) error {
39 | return job.RefreshConfigItemSummary30d(ctx.Context)
40 | },
41 | }
42 |
43 | var CatalogRefreshJobs = []*job.Job{
44 | RefreshConfigItemSummary3dView,
45 | RefreshConfigItemSummary7dView,
46 | RefreshConfigItemSummary30dView,
47 | }
48 |
--------------------------------------------------------------------------------
/jobs/event_queue.go:
--------------------------------------------------------------------------------
1 | package jobs
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/flanksource/duty/job"
7 | )
8 |
9 | const eventQueueStaleAge = time.Hour * 24 * 30
10 |
11 | // CleanupEventQueue deletes stale records in the `event_queue` table
12 | func CleanupEventQueue(ctx job.JobRuntime) error {
13 | age := ctx.Properties().Duration("event_queue.maxAge", eventQueueStaleAge)
14 | result := ctx.DB().Exec("DELETE FROM event_queue WHERE NOW() - created_at > ?", age)
15 | if result.Error != nil {
16 | return result.Error
17 | }
18 |
19 | if result.RowsAffected > 0 {
20 | ctx.History.SuccessCount += int(result.RowsAffected)
21 | }
22 |
23 | return nil
24 | }
25 |
--------------------------------------------------------------------------------
/jobs/job_history_cleanup.go:
--------------------------------------------------------------------------------
1 | package jobs
2 |
3 | import (
4 | "fmt"
5 | "time"
6 |
7 | "github.com/flanksource/duty/job"
8 | )
9 |
10 | var cleanupStaleJobHistory = &job.Job{
11 | Name: "CleanupStaleJobHistory",
12 | Schedule: "15 1 * * *", // Everyday at 1:15 AM
13 | Singleton: true,
14 | JobHistory: true,
15 | Retention: job.RetentionFew,
16 | RunNow: true,
17 | Fn: func(ctx job.JobRuntime) error {
18 | staleHistoryMaxAge := ctx.Properties().Duration("job.history.maxAge", time.Hour*24*30)
19 | count, err := job.CleanupStaleHistory(ctx.Context, staleHistoryMaxAge, "", "")
20 | if err != nil {
21 | return fmt.Errorf("error cleaning stale job histories: %w", err)
22 | }
23 |
24 | runningStaleHistoryMaxAge := ctx.Properties().Duration("job.history.running.maxAge", time.Hour*4)
25 | runningStale, err := job.CleanupStaleRunningHistory(ctx.Context, runningStaleHistoryMaxAge)
26 | if err != nil {
27 | return fmt.Errorf("error cleaning stale RUNNING job histories: %w", err)
28 | }
29 |
30 | ctx.History.SuccessCount = count + runningStale
31 | return nil
32 | },
33 | }
34 |
35 | var cleanupStaleAgentJobHistory = &job.Job{
36 | Name: "CleanupStaleAgentJobHistory",
37 | Schedule: "0 1 * * *", // Everyday at 1 AM
38 | Singleton: true,
39 | JobHistory: true,
40 | Retention: job.RetentionFew,
41 | RunNow: true,
42 | Fn: func(ctx job.JobRuntime) error {
43 | itemsToRetain := ctx.Properties().Int("job.history.agentItemsToRetain", 3)
44 | count, err := job.CleanupStaleAgentHistory(ctx.Context, itemsToRetain)
45 | if err != nil {
46 | return fmt.Errorf("error cleaning stale agent job histories: %w", err)
47 | }
48 |
49 | ctx.History.SuccessCount = count
50 | return nil
51 | },
52 | }
53 |
--------------------------------------------------------------------------------
/jobs/notification_history_cleanup.go:
--------------------------------------------------------------------------------
1 | package jobs
2 |
3 | import (
4 | "github.com/flanksource/duty/job"
5 | "github.com/flanksource/incident-commander/db"
6 | )
7 |
8 | func CleanupNotificationSendHistory(ctx job.JobRuntime) error {
9 | count, err := db.DeleteNotificationSendHistory(ctx.Context, 30)
10 | ctx.History.SuccessCount = int(count)
11 | return err
12 | }
13 |
--------------------------------------------------------------------------------
/jobs/team_components.go:
--------------------------------------------------------------------------------
1 | package jobs
2 |
3 | import (
4 | "github.com/flanksource/commons/logger"
5 | "github.com/flanksource/duty/job"
6 | "github.com/flanksource/incident-commander/db"
7 | "github.com/flanksource/incident-commander/teams"
8 | )
9 |
10 | func TeamComponentOwnershipRun(ctx job.JobRuntime) error {
11 | logger.Debugf("Sync team components")
12 | teamComponentMap := db.GetTeamsWithComponentSelector(ctx.Context)
13 | for teamID, compSelectors := range teamComponentMap {
14 | teamComponents, err := teams.GetTeamComponentsFromSelectors(ctx.Context, teamID, compSelectors)
15 | if err != nil {
16 | return err
17 | }
18 |
19 | if err := db.PersistTeamComponents(ctx.Context, teamComponents); err != nil {
20 | return err
21 | }
22 | }
23 |
24 | return nil
25 | }
26 |
--------------------------------------------------------------------------------
/k8s/client.go:
--------------------------------------------------------------------------------
1 | package k8s
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/flanksource/commons/files"
8 | "k8s.io/client-go/kubernetes"
9 | "k8s.io/client-go/rest"
10 | "k8s.io/client-go/tools/clientcmd"
11 | clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
12 | )
13 |
14 | func NewClient() (kubernetes.Interface, error) {
15 | kubeconfig := os.Getenv("KUBECONFIG")
16 | if kubeconfig == "" {
17 | kubeconfig = os.ExpandEnv("$HOME/.kube/config")
18 | }
19 |
20 | if !files.Exists(kubeconfig) {
21 | if config, err := rest.InClusterConfig(); err == nil {
22 | return kubernetes.NewForConfig(config)
23 | } else {
24 | return nil, fmt.Errorf("cannot find kubeconfig")
25 | }
26 | }
27 |
28 | data, err := os.ReadFile(kubeconfig)
29 | if err != nil {
30 | return nil, err
31 | }
32 | restConfig, err := clientcmd.RESTConfigFromKubeConfig(data)
33 | if err != nil {
34 | return nil, err
35 | }
36 | return kubernetes.NewForConfig(restConfig)
37 | }
38 |
39 | func NewClientWithConfig(kubeConfig string) (kubernetes.Interface, error) {
40 | getter := func() (*clientcmdapi.Config, error) {
41 | clientCfg, err := clientcmd.NewClientConfigFromBytes([]byte(kubeConfig))
42 | if err != nil {
43 | return nil, err
44 | }
45 |
46 | apiCfg, err := clientCfg.RawConfig()
47 | if err != nil {
48 | return nil, err
49 | }
50 |
51 | return &apiCfg, nil
52 | }
53 |
54 | config, err := clientcmd.BuildConfigFromKubeconfigGetter("", getter)
55 | if err != nil {
56 | return nil, fmt.Errorf("failed to generate rest config: %w", err)
57 | }
58 |
59 | return kubernetes.NewForConfig(config)
60 | }
61 |
--------------------------------------------------------------------------------
/llm/cost_test.go:
--------------------------------------------------------------------------------
1 | package llm
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/flanksource/incident-commander/api"
8 | "github.com/onsi/gomega"
9 | )
10 |
11 | func TestCalculateCost(t *testing.T) {
12 | type testCase struct {
13 | provider api.LLMBackend
14 | model string
15 | inputTokens int
16 | outputTokens int
17 | expectedCost float64
18 | }
19 |
20 | // Reference: https://gptforwork.com/tools/openai-chatgpt-api-pricing-calculator
21 | tests := []testCase{
22 | {
23 | provider: api.LLMBackendOpenAI,
24 | model: "o1",
25 | inputTokens: 15000,
26 | outputTokens: 20000,
27 | expectedCost: 1.425,
28 | },
29 | {
30 | provider: api.LLMBackendOpenAI,
31 | model: "gpt-4o",
32 | inputTokens: 15000,
33 | outputTokens: 20000,
34 | expectedCost: 0.2375,
35 | },
36 | {
37 | provider: api.LLMBackendAnthropic,
38 | model: "claude-3-5-sonnet-latest",
39 | inputTokens: 15000,
40 | outputTokens: 20000,
41 | expectedCost: 0.3450,
42 | },
43 | }
44 |
45 | for _, test := range tests {
46 | t.Run(fmt.Sprintf("%s %s", test.provider, test.model), func(t *testing.T) {
47 | g := gomega.NewGomegaWithT(t)
48 | cost, err := CalculateCost(test.provider, test.model, GenerationInfo{
49 | InputTokens: test.inputTokens,
50 | OutputTokens: test.outputTokens,
51 | })
52 | g.Expect(err).To(gomega.BeNil())
53 | g.Expect(cost).To(gomega.BeNumerically("~", test.expectedCost, 0.0001), "expected to be equal to 4 decimal places")
54 | })
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/llm/types.go:
--------------------------------------------------------------------------------
1 | package llm
2 |
3 | type DiagnosisReport struct {
4 | RecommendedFix string `json:"recommended_fix"`
5 | Headline string `json:"headline"`
6 | Summary string `json:"summary"`
7 | }
8 |
9 | type PlaybookRecommendations struct {
10 | Playbooks []RecommendedPlaybook `json:"playbooks"`
11 | }
12 |
13 | type RecommendedPlaybook struct {
14 | ID string `json:"id"`
15 | Title string `json:"title"`
16 | Emoji string `json:"emoji"`
17 | ResourceID string `json:"resource_id"`
18 |
19 | // NOTE: Parameters is a list of map instead of a map because
20 | // we need to provide the exact schema to the LLMs.
21 | // i.e. the JSON schema would need to contain the exact parameters of the playbook.
22 | // That's not possible as we don't know which playbook the LLM will pick.
23 | //
24 | // Making it a list of map allows us to pass in a strict type-safe schema to the LLM.
25 | Parameters []PlaybookParameters `json:"parameters"`
26 | }
27 |
28 | type PlaybookParameters struct {
29 | Key string `json:"key"`
30 | Value string `json:"value"`
31 | }
32 |
--------------------------------------------------------------------------------
/logs/cloudwatch/types.go:
--------------------------------------------------------------------------------
1 | package cloudwatch
2 |
3 | import (
4 | "github.com/flanksource/incident-commander/logs"
5 | )
6 |
7 | // +kubebuilder:object:generate=true
8 | type Request struct {
9 | logs.LogsRequestBase `json:",inline" template:"true"`
10 |
11 | // The log group on which to perform the query.
12 | LogGroup string `json:"logGroup" template:"true"`
13 |
14 | // The query to perform on the log group.
15 | Query string `json:"query" template:"true"`
16 | }
17 |
18 | type Event struct {
19 | ID string `json:"id,omitempty"`
20 | Time string `json:"timestamp,omitempty"`
21 | Message string `json:"message,omitempty"`
22 | Labels map[string]string `json:"labels,omitempty"`
23 | }
24 |
--------------------------------------------------------------------------------
/logs/cloudwatch/zz_generated.deepcopy.go:
--------------------------------------------------------------------------------
1 | //go:build !ignore_autogenerated
2 |
3 | // Code generated by controller-gen. DO NOT EDIT.
4 |
5 | package cloudwatch
6 |
7 | import ()
8 |
9 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
10 | func (in *Request) DeepCopyInto(out *Request) {
11 | *out = *in
12 | out.LogsRequestBase = in.LogsRequestBase
13 | }
14 |
15 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Request.
16 | func (in *Request) DeepCopy() *Request {
17 | if in == nil {
18 | return nil
19 | }
20 | out := new(Request)
21 | in.DeepCopyInto(out)
22 | return out
23 | }
24 |
--------------------------------------------------------------------------------
/logs/config.go:
--------------------------------------------------------------------------------
1 | package logs
2 |
3 | // FieldMappingConfig defines how source log fields map to canonical LogLine fields.
4 | // Each key represents a canonical field (e.g., "message", "timestamp"),
5 | // and the value is a list of possible source field names.
6 | //
7 | // +kubebuilder:object:generate=true
8 | type FieldMappingConfig struct {
9 | ID []string `json:"id,omitempty" yaml:"id,omitempty"`
10 | Message []string `json:"message,omitempty" yaml:"message,omitempty"`
11 | Timestamp []string `json:"timestamp,omitempty" yaml:"timestamp,omitempty"`
12 | Host []string `json:"host,omitempty" yaml:"host,omitempty"`
13 | Severity []string `json:"severity,omitempty" yaml:"severity,omitempty"`
14 | Source []string `json:"source,omitempty" yaml:"source,omitempty"`
15 | Ignore []string `json:"ignore,omitempty" yaml:"ignore,omitempty"`
16 | }
17 |
18 | func (c FieldMappingConfig) WithDefaults(defaultMap FieldMappingConfig) FieldMappingConfig {
19 | if len(c.ID) == 0 {
20 | c.ID = defaultMap.ID
21 | }
22 | if len(c.Message) == 0 {
23 | c.Message = defaultMap.Message
24 | }
25 | if len(c.Timestamp) == 0 {
26 | c.Timestamp = defaultMap.Timestamp
27 | }
28 | if len(c.Severity) == 0 {
29 | c.Severity = defaultMap.Severity
30 | }
31 | if len(c.Source) == 0 {
32 | c.Source = defaultMap.Source
33 | }
34 | if len(c.Ignore) == 0 {
35 | c.Ignore = defaultMap.Ignore
36 | }
37 | if len(c.Host) == 0 {
38 | c.Host = defaultMap.Host
39 | }
40 | return c
41 | }
42 |
43 | func (c FieldMappingConfig) Empty() bool {
44 | return len(c.ID) == 0 && len(c.Message) == 0 && len(c.Timestamp) == 0 && len(c.Severity) == 0 && len(c.Source) == 0 && len(c.Ignore) == 0 && len(c.Host) == 0
45 | }
46 |
--------------------------------------------------------------------------------
/logs/k8s/zz_generated.deepcopy.go:
--------------------------------------------------------------------------------
1 | //go:build !ignore_autogenerated
2 |
3 | // Code generated by controller-gen. DO NOT EDIT.
4 |
5 | package k8s
6 |
7 | import (
8 | "github.com/flanksource/duty/types"
9 | )
10 |
11 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
12 | func (in *Request) DeepCopyInto(out *Request) {
13 | *out = *in
14 | out.LogsRequestBase = in.LogsRequestBase
15 | if in.Pods != nil {
16 | in, out := &in.Pods, &out.Pods
17 | *out = make(types.ResourceSelectors, len(*in))
18 | for i := range *in {
19 | (*in)[i].DeepCopyInto(&(*out)[i])
20 | }
21 | }
22 | if in.Containers != nil {
23 | in, out := &in.Containers, &out.Containers
24 | *out = make(types.MatchExpressions, len(*in))
25 | copy(*out, *in)
26 | }
27 | }
28 |
29 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Request.
30 | func (in *Request) DeepCopy() *Request {
31 | if in == nil {
32 | return nil
33 | }
34 | out := new(Request)
35 | in.DeepCopyInto(out)
36 | return out
37 | }
38 |
--------------------------------------------------------------------------------
/logs/loki/zz_generated.deepcopy.go:
--------------------------------------------------------------------------------
1 | //go:build !ignore_autogenerated
2 |
3 | // Code generated by controller-gen. DO NOT EDIT.
4 |
5 | package loki
6 |
7 | import ()
8 |
9 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
10 | func (in *Request) DeepCopyInto(out *Request) {
11 | *out = *in
12 | out.LogsRequestBase = in.LogsRequestBase
13 | }
14 |
15 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Request.
16 | func (in *Request) DeepCopy() *Request {
17 | if in == nil {
18 | return nil
19 | }
20 | out := new(Request)
21 | in.DeepCopyInto(out)
22 | return out
23 | }
24 |
--------------------------------------------------------------------------------
/logs/mapping_test.go:
--------------------------------------------------------------------------------
1 | package logs
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func Test_mapify(t *testing.T) {
10 | tests := []struct {
11 | name string
12 | input any
13 | want map[string]string
14 | wantErr bool
15 | }{
16 | {
17 | name: "nil input",
18 | input: nil,
19 | want: nil,
20 | },
21 | {
22 | name: "string input",
23 | input: "value",
24 | want: map[string]string{
25 | "": "value",
26 | },
27 | },
28 | {
29 | name: "simple map input",
30 | input: map[string]any{
31 | "key1": "value1",
32 | "key2": "value2",
33 | },
34 | want: map[string]string{
35 | "key1": "value1",
36 | "key2": "value2",
37 | },
38 | },
39 | {
40 | name: "nested map input",
41 | input: map[string]any{
42 | "level1": map[string]any{
43 | "level2": "value",
44 | },
45 | },
46 | want: map[string]string{
47 | "level1.level2": "value",
48 | },
49 | },
50 | {
51 | name: "multiple nested levels",
52 | input: map[string]any{
53 | "a": map[string]any{
54 | "b": map[string]any{
55 | "c": "value",
56 | },
57 | },
58 | },
59 | want: map[string]string{
60 | "a.b.c": "value",
61 | },
62 | },
63 | }
64 |
65 | for _, tt := range tests {
66 | t.Run(tt.name, func(t *testing.T) {
67 | got, err := flatMap("", tt.input)
68 | if tt.wantErr {
69 | assert.Error(t, err)
70 | return
71 | }
72 | assert.NoError(t, err)
73 | assert.Equal(t, tt.want, got)
74 | })
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/logs/opensearch/zz_generated.deepcopy.go:
--------------------------------------------------------------------------------
1 | //go:build !ignore_autogenerated
2 |
3 | // Code generated by controller-gen. DO NOT EDIT.
4 |
5 | package opensearch
6 |
7 | import (
8 | "github.com/flanksource/duty/types"
9 | )
10 |
11 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
12 | func (in *Backend) DeepCopyInto(out *Backend) {
13 | *out = *in
14 | if in.Username != nil {
15 | in, out := &in.Username, &out.Username
16 | *out = new(types.EnvVar)
17 | (*in).DeepCopyInto(*out)
18 | }
19 | if in.Password != nil {
20 | in, out := &in.Password, &out.Password
21 | *out = new(types.EnvVar)
22 | (*in).DeepCopyInto(*out)
23 | }
24 | }
25 |
26 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Backend.
27 | func (in *Backend) DeepCopy() *Backend {
28 | if in == nil {
29 | return nil
30 | }
31 | out := new(Backend)
32 | in.DeepCopyInto(out)
33 | return out
34 | }
35 |
36 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
37 | func (in *Request) DeepCopyInto(out *Request) {
38 | *out = *in
39 | }
40 |
41 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Request.
42 | func (in *Request) DeepCopy() *Request {
43 | if in == nil {
44 | return nil
45 | }
46 | out := new(Request)
47 | in.DeepCopyInto(out)
48 | return out
49 | }
50 |
--------------------------------------------------------------------------------
/logs/zz_generated.deepcopy.go:
--------------------------------------------------------------------------------
1 | //go:build !ignore_autogenerated
2 |
3 | // Code generated by controller-gen. DO NOT EDIT.
4 |
5 | package logs
6 |
7 | import ()
8 |
9 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
10 | func (in *FieldMappingConfig) DeepCopyInto(out *FieldMappingConfig) {
11 | *out = *in
12 | if in.ID != nil {
13 | in, out := &in.ID, &out.ID
14 | *out = make([]string, len(*in))
15 | copy(*out, *in)
16 | }
17 | if in.Message != nil {
18 | in, out := &in.Message, &out.Message
19 | *out = make([]string, len(*in))
20 | copy(*out, *in)
21 | }
22 | if in.Timestamp != nil {
23 | in, out := &in.Timestamp, &out.Timestamp
24 | *out = make([]string, len(*in))
25 | copy(*out, *in)
26 | }
27 | if in.Host != nil {
28 | in, out := &in.Host, &out.Host
29 | *out = make([]string, len(*in))
30 | copy(*out, *in)
31 | }
32 | if in.Severity != nil {
33 | in, out := &in.Severity, &out.Severity
34 | *out = make([]string, len(*in))
35 | copy(*out, *in)
36 | }
37 | if in.Source != nil {
38 | in, out := &in.Source, &out.Source
39 | *out = make([]string, len(*in))
40 | copy(*out, *in)
41 | }
42 | if in.Ignore != nil {
43 | in, out := &in.Ignore, &out.Ignore
44 | *out = make([]string, len(*in))
45 | copy(*out, *in)
46 | }
47 | }
48 |
49 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FieldMappingConfig.
50 | func (in *FieldMappingConfig) DeepCopy() *FieldMappingConfig {
51 | if in == nil {
52 | return nil
53 | }
54 | out := new(FieldMappingConfig)
55 | in.DeepCopyInto(out)
56 | return out
57 | }
58 |
--------------------------------------------------------------------------------
/mail/mailer.go:
--------------------------------------------------------------------------------
1 | package mail
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "strconv"
7 |
8 | "gopkg.in/gomail.v2"
9 | )
10 |
11 | var (
12 | FromAddress string
13 | FromName string
14 | )
15 |
16 | type Mail struct {
17 | message *gomail.Message
18 | dialer *gomail.Dialer
19 | }
20 |
21 | func New(to, subject, body, contentType string) *Mail {
22 | m := gomail.NewMessage()
23 | m.SetHeader("From", fmt.Sprintf("%s <%s>", FromName, FromAddress))
24 | m.SetHeader("To", to)
25 | m.SetHeader("Subject", subject)
26 | m.SetBody(contentType, body)
27 | return &Mail{message: m}
28 | }
29 |
30 | func (t *Mail) SetFrom(name, email string) *Mail {
31 | t.message.SetHeader("From", fmt.Sprintf("%s <%s>", name, email))
32 | return t
33 | }
34 |
35 | func (t *Mail) SetHeader(key string, value string) *Mail {
36 | t.message.SetHeader(key, value)
37 | return t
38 | }
39 |
40 | func (t *Mail) SetCredentials(host string, port int, user, password string) *Mail {
41 | t.dialer = gomail.NewDialer(host, port, user, password)
42 | return t
43 | }
44 |
45 | func (m Mail) Send() error {
46 | if m.dialer == nil {
47 | host := os.Getenv("SMTP_HOST")
48 | user := os.Getenv("SMTP_USER")
49 | password := os.Getenv("SMTP_PASSWORD")
50 | port, _ := strconv.Atoi(os.Getenv("SMTP_PORT"))
51 | m.SetCredentials(host, port, user, password)
52 | }
53 |
54 | return m.dialer.DialAndSend(m.message)
55 | }
56 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/flanksource/incident-commander/api"
8 | "github.com/flanksource/incident-commander/cmd"
9 | "github.com/spf13/cobra"
10 | )
11 |
12 | var (
13 | version = "dev"
14 | commit = "none"
15 | date = "unknown"
16 | )
17 |
18 | func main() {
19 | if len(commit) > 8 {
20 | version = fmt.Sprintf("%v, commit %v, built at %v", version, commit[0:8], date)
21 | }
22 |
23 | api.BuildVersion = version
24 |
25 | cmd.Root.AddCommand(&cobra.Command{
26 | Use: "version",
27 | Short: "Print the version of incident-commander",
28 | Args: cobra.MinimumNArgs(0),
29 | Run: func(cmd *cobra.Command, args []string) {
30 | fmt.Println(version)
31 | },
32 | })
33 | cmd.Root.SetUsageTemplate(cmd.Root.UsageTemplate() + fmt.Sprintf("\nversion: %s\n ", version))
34 |
35 | if err := cmd.Root.Execute(); err != nil {
36 | os.Exit(1)
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/notification/controllers.go:
--------------------------------------------------------------------------------
1 | package notification
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 |
7 | "github.com/flanksource/duty/api"
8 | "github.com/flanksource/duty/context"
9 | "github.com/flanksource/duty/models"
10 | "github.com/flanksource/duty/query"
11 | "github.com/flanksource/duty/rbac/policy"
12 | echoSrv "github.com/flanksource/incident-commander/echo"
13 | "github.com/flanksource/incident-commander/rbac"
14 | "github.com/labstack/echo/v4"
15 | )
16 |
17 | func init() {
18 | echoSrv.RegisterRoutes(RegisterRoutes)
19 | }
20 |
21 | func RegisterRoutes(e *echo.Echo) {
22 | g := e.Group("/notification")
23 |
24 | g.POST("/summary", NotificationSendHistorySummary, echoSrv.RLSMiddleware)
25 |
26 | g.GET("/events", func(c echo.Context) error {
27 | return c.JSON(http.StatusOK, EventRing.Get())
28 | }, rbac.Authorization(policy.ObjectMonitor, policy.ActionRead))
29 |
30 | g.POST("/silence", func(c echo.Context) error {
31 | ctx := c.Request().Context().(context.Context)
32 |
33 | var req SilenceSaveRequest
34 | if err := json.NewDecoder(c.Request().Body).Decode(&req); err != nil {
35 | return err
36 | }
37 |
38 | req.Source = models.SourceUI
39 | if err := SaveNotificationSilence(ctx, req); err != nil {
40 | return api.WriteError(c, err)
41 | }
42 |
43 | return nil
44 | }, rbac.Authorization(policy.ObjectNotification, policy.ActionCreate))
45 | }
46 |
47 | func NotificationSendHistorySummary(c echo.Context) error {
48 | var req query.NotificationSendHistorySummaryRequest
49 | if err := c.Bind(&req); err != nil {
50 | return err
51 | }
52 |
53 | ctx := c.Request().Context().(context.Context)
54 |
55 | response, err := query.NotificationSendHistorySummary(ctx, req)
56 | if err != nil {
57 | return api.WriteError(c, err)
58 | }
59 |
60 | return c.JSON(http.StatusOK, response)
61 | }
62 |
--------------------------------------------------------------------------------
/notification/events_test.go:
--------------------------------------------------------------------------------
1 | package notification
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/flanksource/duty/models"
7 | )
8 |
9 | func TestIsHealthReportable(t *testing.T) {
10 | tests := []struct {
11 | name string
12 | events []string
13 | previousHealth models.Health
14 | currentHealth models.Health
15 | expected bool
16 | }{
17 | {
18 | name: "health worsened",
19 | events: []string{"config.warning", "config.healthy"},
20 | previousHealth: models.HealthHealthy,
21 | currentHealth: models.HealthWarning,
22 | expected: false,
23 | },
24 | {
25 | name: "health changed and got better",
26 | events: []string{"config.warning", "config.healthy"},
27 | previousHealth: models.HealthWarning,
28 | currentHealth: models.HealthHealthy,
29 | expected: false,
30 | },
31 | {
32 | name: "Current health not in notification",
33 | events: []string{"config.healthy"},
34 | previousHealth: models.HealthHealthy,
35 | currentHealth: models.HealthUnhealthy,
36 | expected: false,
37 | },
38 | {
39 | name: "health unchanged",
40 | events: []string{"config.warning", "config.healthy"},
41 | previousHealth: models.HealthHealthy,
42 | currentHealth: models.HealthHealthy,
43 | expected: true,
44 | },
45 | }
46 |
47 | for _, tt := range tests {
48 | t.Run(tt.name, func(t *testing.T) {
49 | result := isHealthReportable(tt.events, tt.previousHealth, tt.currentHealth)
50 | if result != tt.expected {
51 | t.Errorf("isHealthReportable(%v, %v, %v) = %v; want %v",
52 | tt.events, tt.previousHealth, tt.currentHealth, result, tt.expected)
53 | }
54 | })
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/notification/metrics.go:
--------------------------------------------------------------------------------
1 | package notification
2 |
3 | import "github.com/prometheus/client_golang/prometheus"
4 |
5 | func init() {
6 | prometheus.MustRegister(notificationSentCounter, notificationSendFailureCounter, notificationSendDuration)
7 | }
8 |
9 | var (
10 | notificationSentCounter = prometheus.NewCounterVec(
11 | prometheus.CounterOpts{
12 | Name: "sent_total",
13 | Subsystem: "notification",
14 | Help: "Total number of notifications sent",
15 | },
16 | []string{"service", "recipient_type", "id"},
17 | )
18 |
19 | notificationSendFailureCounter = prometheus.NewCounterVec(
20 | prometheus.CounterOpts{
21 | Name: "send_error_total",
22 | Subsystem: "notification",
23 | Help: "Total number of failure notifications sent",
24 | },
25 | []string{"service", "recipient_type", "id"},
26 | )
27 |
28 | notificationSendDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
29 | Name: "send_duration_seconds",
30 | Subsystem: "notification",
31 | Help: "Duration to send a notification.",
32 | }, []string{"service", "recipient_type", "id"})
33 | )
34 |
--------------------------------------------------------------------------------
/notification/ratelimit.go:
--------------------------------------------------------------------------------
1 | package notification
2 |
3 | import (
4 | "time"
5 |
6 | sw "github.com/RussellLuo/slidingwindow"
7 | )
8 |
9 | // LocalWindow represents a window that ignores sync behavior entirely
10 | // and only stores counters in memory.
11 | //
12 | // NOTE: It's an exact copy of the LocalWindow provided by RussellLuo/slidingwindow
13 | // with an added capability of setting a custom start time.
14 | type LocalWindow struct {
15 | // The start boundary (timestamp in nanoseconds) of the window.
16 | // [start, start + size)
17 | start int64
18 |
19 | // The total count of events happened in the window.
20 | count int64
21 | }
22 |
23 | func NewLocalWindow() (*LocalWindow, sw.StopFunc) {
24 | return &LocalWindow{}, func() {}
25 | }
26 |
27 | func (w *LocalWindow) SetStart(s time.Time) {
28 | w.start = s.UnixNano()
29 | }
30 |
31 | func (w *LocalWindow) Start() time.Time {
32 | return time.Unix(0, w.start)
33 | }
34 |
35 | func (w *LocalWindow) Count() int64 {
36 | return w.count
37 | }
38 |
39 | func (w *LocalWindow) AddCount(n int64) {
40 | w.count += n
41 | }
42 |
43 | func (w *LocalWindow) Reset(s time.Time, c int64) {
44 | w.start = s.UnixNano()
45 | w.count = c
46 | }
47 |
48 | func (w *LocalWindow) Sync(now time.Time) {}
49 |
--------------------------------------------------------------------------------
/notification/slack.go:
--------------------------------------------------------------------------------
1 | package notification
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "strings"
8 |
9 | "github.com/slack-go/slack"
10 | )
11 |
12 | type SlackMsgTemplate struct {
13 | Blocks slack.Blocks `json:"blocks"`
14 | }
15 |
16 | func SlackSend(ctx *Context, apiToken, channel string, msg NotificationTemplate) error {
17 | if channel == "" {
18 | return errors.New("slack channel cannot be empty")
19 | }
20 |
21 | api := slack.New(apiToken)
22 |
23 | var opts []slack.MsgOption
24 | if msg.Title != "" {
25 | opts = append(opts, slack.MsgOptionText(msg.Title, false))
26 | }
27 |
28 | // keep track of the message body for notification send history.
29 | // we can't JSON marshal opts (type []slack.MsgOption)
30 | var msgBody []any
31 |
32 | if msg.Message != "" {
33 | if strings.Contains(msg.Message, `"blocks"`) {
34 | var slackMsg SlackMsgTemplate
35 | if err := json.Unmarshal([]byte(msg.Message), &slackMsg); err != nil {
36 | return fmt.Errorf("failed to unmarshal slack template into blocks: %w", err)
37 | }
38 |
39 | opts = append(opts, slack.MsgOptionBlocks(slackMsg.Blocks.BlockSet...))
40 | msgBody = append(msgBody, slackMsg)
41 | } else {
42 | opts = append(opts, slack.MsgOptionText(msg.Message, false))
43 | msgBody = append(msgBody, msg.Message)
44 | }
45 | }
46 |
47 | if b, err := json.Marshal(msgBody); err != nil {
48 | ctx.WithMessage(msg.Message)
49 | } else {
50 | ctx.WithMessage(string(b))
51 | }
52 |
53 | _, _, err := api.PostMessageContext(ctx, channel, opts...)
54 |
55 | var slackError slack.SlackErrorResponse
56 | if errors.As(err, &slackError) {
57 | switch slackError.Err {
58 | case "channel_not_found":
59 | return fmt.Errorf("slack channel %q not found. ensure the channel exists & the bot has permission on that channel", channel)
60 | }
61 | }
62 |
63 | return ctx.Oops().Hint(msg.Message).Wrap(err)
64 | }
65 |
--------------------------------------------------------------------------------
/notification/templates/check.failed:
--------------------------------------------------------------------------------
1 | {{ if eq channel "slack"}}
2 | {
3 | "blocks": [
4 | {{slackSectionTextMD (printf `:red_circle: *%s* is _unhealthy_` .check.name)}},
5 | {"type": "divider"},
6 | {{ if .check_status.error}}{{slackSectionTextMD check_status.error}},{{end}}
7 | {
8 | "type": "section",
9 | "fields": [
10 | {{slackSectionTextFieldMD (printf `*Canary*: %s` .canary.name) }},
11 | {{slackSectionTextFieldMD (printf `*Namespace*: %s` .canary.namespace) }}
12 | {{if ne .agent.name "local"}}
13 | ,{{slackSectionTextFieldMD (printf `*Agent*: %s` .agent.name) }}
14 | {{end}}
15 | ]
16 | },
17 | {{ if .check.labels}}{{slackSectionLabels .check}},{{end}}
18 | {{if .groupedResources}}{{slackSectionTextMD (printf `*Resources grouped with notification:* %s` (join .groupedResources "\n"))}},{{end}}
19 | {{ slackURLAction "View Health Check" .permalink "🔕 Silence" .silenceURL}}
20 | ]
21 | }
22 | {{ else }}
23 | Canary: {{.canary.name}}
24 | {{if .agent}}Agent: {{.agent.name}}{{end}}
25 | Error: {{.check_status.error}}
26 | {{labelsFormat .check.labels}}
27 |
28 | [Reference]({{.permalink}})
29 | {{end}}
30 |
--------------------------------------------------------------------------------
/notification/templates/check.passed:
--------------------------------------------------------------------------------
1 | {{ if eq .channel "slack"}}
2 | {
3 | "blocks": [
4 | {{slackSectionTextMD (printf `:large_green_circle: *%s* is _healthy_` .canary.name)}},
5 | {"type": "divider"},
6 | {{ if .check_status.message}}{{slackSectionTextMD check_status.message}},{{end}}
7 | {
8 | "type": "section",
9 | "fields": [
10 | {{slackSectionTextFieldMD (printf `*Canary*: %s` .canary.name) }},
11 | {{slackSectionTextFieldMD (printf `*Namespace*: %s` .canary.namespace) }}
12 | {{if ne .agent.name "local"}}
13 | ,{{slackSectionTextFieldMD (printf `*Agent*: %s` .agent.name) }}
14 | {{end}}
15 | ]
16 | },
17 | {{ if .check.labels}}{{slackSectionLabels .check}},{{end}}
18 | {{if .groupedResources}}{{slackSectionTextMD (printf `*Resources grouped with notification:* %s` (join .groupedResources "\n"))}},{{end}}
19 | {{ slackURLAction "View Health Check" .permalink "🔕 Silence" .silenceURL}}
20 | ]
21 | }
22 | {{ else }}
23 | Canary: {{.canary.name}}
24 | {{if .agent}}Agent: {{.agent.name}}{{end}}
25 | {{if .check_status.message}}Message: {{.check_status.message}} {{end}}
26 | {{labelsFormat .check.labels}}
27 |
28 | [Reference]({{.permalink}})
29 | {{end}}
--------------------------------------------------------------------------------
/notification/templates/component.health:
--------------------------------------------------------------------------------
1 | {{if eq channel "slack"}}
2 | {
3 | "blocks": [
4 | {{slackSectionTextMD (printf `%s *%s* is _%s_` (slackHealthEmoji .component.health) .component.name .component.health)}},
5 | {"type": "divider"},
6 | {{if .component.description}}{{slackSectionTextPlain .component.description}},{{end}}
7 | {
8 | "type": "section",
9 | "fields": [
10 | {{slackSectionTextFieldMD (printf `*Type*: %s` .component.type) }}
11 | {{if .component.status}},{{slackSectionTextFieldMD (printf `*Status*: %s` .component.status) }}{{end}}
12 | {{if ne .agent.name "local"}}
13 | ,{{slackSectionTextFieldMD (printf `*Agent*: %s` .agent.name)}}
14 | {{end}}
15 | ]
16 | },
17 | {{if .component.labels}}{{slackSectionLabels .component}},{{end}}
18 | {{if .groupedResources}}{{slackSectionTextMD (printf `*Also Failing:* - %s` (join .groupedResources "\n - "))}},{{end}}
19 | {{slackURLAction "View Component" .permalink "🔕 Silence" .silenceURL}}
20 | ]
21 | }
22 |
23 | {{else}}
24 | {{labelsFormat .component.labels}}
25 |
26 | [Reference]({{.permalink}})
27 | {{end}}
28 |
--------------------------------------------------------------------------------
/notification/templates/config.db.update:
--------------------------------------------------------------------------------
1 | {{if eq channel "slack"}}
2 | {
3 | "blocks": [
4 | {{slackSectionTextMD (printf `:information_source: *%s* was _%s_` .config.name .new_state)}},
5 | {"type": "divider"},
6 | {{if .config.description}}{{slackSectionTextPlain .config.description}},{{end}}
7 | {
8 | "type": "section",
9 | "fields": [
10 | {{slackSectionTextFieldMD (printf `*Type*: %s` .config.type) }}
11 | {{if .config.status}},{{slackSectionTextFieldMD (printf `*Status*: %s` .config.status) }}{{end}}
12 | {{if ne .agent.name "local"}}
13 | ,{{slackSectionTextFieldMD (printf `*Agent*: %s` .agent.name)}}
14 | {{end}}
15 | ]
16 | },
17 | {{if .config.labels}}{{slackSectionLabels .config}},{{end}}
18 | {{if .groupedResources}}{{slackSectionTextMD (printf `*Also Failing:* - %s` (join .groupedResources "\n - "))}},{{end}}
19 | {{slackURLAction "View Catalog" .permalink "🔕 Silence" .silenceURL}}
20 | ]
21 | }
22 |
23 | {{else}}
24 | {{labelsFormat .config.labels}}
25 |
26 | [Reference]({{.permalink}})
27 | {{end}}
28 |
--------------------------------------------------------------------------------
/notification/templates/config.health:
--------------------------------------------------------------------------------
1 | {{if eq channel "slack"}}
2 | {
3 | "blocks": [
4 | {{slackSectionTextMD (printf `%s *%s* is _%s_` (slackHealthEmoji .config.health) .config.name .config.health)}},
5 | {"type": "divider"},
6 | {{if .config.description}}{{slackSectionTextPlain .config.description}},{{end}}
7 | {
8 | "type": "section",
9 | "fields": [
10 | {{slackSectionTextFieldMD (printf `*Type*: %s` .config.type) }}
11 | {{if .config.status}},{{slackSectionTextFieldMD (printf `*Status*: %s` .config.status) }}{{end}}
12 | {{if ne .agent.name "local"}}
13 | ,{{slackSectionTextFieldMD (printf `*Agent*: %s` .agent.name)}}
14 | {{end}}
15 | ]
16 | },
17 | {{if .config.labels}}{{slackSectionLabels .config}},{{end}}
18 | {{if .recent_events}}{{slackSectionTextMD (printf `*Recent Events:* %v` (join .recent_events ", "))}},{{end}}
19 | {{if .groupedResources}}{{slackSectionTextMD (printf `*Also Failing:* - %s` (join .groupedResources "\n - "))}},{{end}}
20 | {"type": "divider"},
21 | {{slackURLAction "View Catalog" .permalink "🔕 Silence" .silenceURL}}
22 | ]
23 | }
24 |
25 | {{else}}
26 | {{labelsFormat .config.labels}}
27 |
28 | [Reference]({{.permalink}})
29 | {{end}}
30 |
--------------------------------------------------------------------------------
/pkg/clients/aws/aws.go:
--------------------------------------------------------------------------------
1 | package aws
2 |
3 | import (
4 | "crypto/tls"
5 | "net/http"
6 |
7 | "github.com/aws/aws-sdk-go-v2/aws"
8 | "github.com/aws/aws-sdk-go-v2/config"
9 | "github.com/aws/aws-sdk-go-v2/credentials"
10 | "github.com/flanksource/duty/connection"
11 | "github.com/flanksource/duty/context"
12 | "github.com/henvic/httpretty"
13 | )
14 |
15 | func GetAWSConfig(ctx *context.Context, conn connection.AWSConnection) (cfg aws.Config, err error) {
16 | var options []func(*config.LoadOptions) error
17 |
18 | if conn.Region != "" {
19 | options = append(options, config.WithRegion(conn.Region))
20 | }
21 |
22 | if conn.Endpoint != "" {
23 | // nolint:staticcheck // TODO: use the client from duty
24 | options = append(options, config.WithEndpointResolverWithOptions(aws.EndpointResolverWithOptionsFunc(
25 | func(service, region string, options ...any) (aws.Endpoint, error) {
26 | return aws.Endpoint{
27 | URL: conn.Endpoint,
28 | }, nil
29 | },
30 | )))
31 | }
32 |
33 | if !conn.AccessKey.IsEmpty() {
34 | options = append(options, config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(conn.AccessKey.ValueStatic, conn.SecretKey.ValueStatic, "")))
35 | }
36 |
37 | if conn.SkipTLSVerify {
38 | var tr http.RoundTripper
39 | if ctx.IsTrace() {
40 | httplogger := &httpretty.Logger{
41 | Time: true,
42 | TLS: false,
43 | RequestHeader: false,
44 | RequestBody: false,
45 | ResponseHeader: true,
46 | ResponseBody: false,
47 | Colors: true,
48 | Formatters: []httpretty.Formatter{&httpretty.JSONFormatter{}},
49 | }
50 | tr = httplogger.RoundTripper(tr)
51 | } else {
52 | tr = &http.Transport{
53 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
54 | }
55 | }
56 |
57 | options = append(options, config.WithHTTPClient(&http.Client{Transport: tr}))
58 | }
59 |
60 | return config.LoadDefaultConfig(ctx, options...)
61 | }
62 |
--------------------------------------------------------------------------------
/playbook/actions/azure_devops_pipeline.go:
--------------------------------------------------------------------------------
1 | package actions
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/flanksource/commons/http"
7 | "github.com/flanksource/duty/context"
8 | v1 "github.com/flanksource/incident-commander/api/v1"
9 | )
10 |
11 | type AzureDevopsPipeline struct {
12 | }
13 |
14 | func (t *AzureDevopsPipeline) Run(ctx context.Context, spec v1.AzureDevopsPipelineAction) (map[string]any, error) {
15 | token, err := ctx.GetEnvValueFromCache(spec.Token, ctx.GetNamespace())
16 | if err != nil {
17 | return nil, fmt.Errorf("could not get azure devops token from env: %v", err)
18 | }
19 |
20 | pipeline := spec.Pipeline
21 |
22 | request := http.NewClient().BaseURL("https://dev.azure.com").Auth("", token).
23 | R(ctx).QueryParam("api-version", "7.1-preview.1").Header("Content-Type", "application/json")
24 | if pipeline.Version != "" {
25 | request = request.QueryParam("api-version", pipeline.Version)
26 | }
27 |
28 | endpoint := fmt.Sprintf("%s/%s/_apis/pipelines/%s/runs", spec.Org, spec.Project, pipeline.ID)
29 | response, err := request.Post(endpoint, spec.Parameters)
30 | if err != nil {
31 | return nil, fmt.Errorf("failed to run pipeline: %w", err)
32 | }
33 |
34 | if response.StatusCode != 200 {
35 | body, _ := response.AsString()
36 | return nil, fmt.Errorf("api returned status code %d for pipeline: %s (response:%s)", response.StatusCode, pipeline.ID, body)
37 | }
38 |
39 | var body map[string]any
40 | if err := response.Into(&body); err != nil {
41 | return nil, fmt.Errorf("failed to read API response: %w", err)
42 | }
43 |
44 | return body, nil
45 | }
46 |
--------------------------------------------------------------------------------
/playbook/actions/exec.go:
--------------------------------------------------------------------------------
1 | package actions
2 |
3 | import (
4 | "github.com/flanksource/artifacts"
5 | "github.com/flanksource/duty/context"
6 | "github.com/flanksource/duty/models"
7 | "github.com/flanksource/duty/shell"
8 | v1 "github.com/flanksource/incident-commander/api/v1"
9 | )
10 |
11 | type ExecAction struct {
12 | }
13 |
14 | type ExecDetails shell.ExecDetails
15 |
16 | func (e *ExecDetails) GetArtifacts() []artifacts.Artifact {
17 | if e == nil {
18 | return nil
19 | }
20 | return e.Artifacts
21 | }
22 |
23 | func (e *ExecDetails) GetStatus() models.PlaybookActionStatus {
24 | if e.ExitCode != 0 {
25 | return models.PlaybookActionStatusFailed
26 | }
27 |
28 | return models.PlaybookActionStatusCompleted
29 | }
30 |
31 | func (c *ExecAction) Run(ctx context.Context, exec v1.ExecAction) (*ExecDetails, error) {
32 | details, err := shell.Run(ctx, exec.ToShellExec())
33 | return (*ExecDetails)(details), err
34 | }
35 |
--------------------------------------------------------------------------------
/playbook/actions/github.go:
--------------------------------------------------------------------------------
1 | package actions
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 |
7 | "github.com/flanksource/commons/http"
8 | "github.com/flanksource/duty/context"
9 | v1 "github.com/flanksource/incident-commander/api/v1"
10 | )
11 |
12 | type Github struct {
13 | }
14 |
15 | type GithubResponse struct {
16 | }
17 |
18 | type githubWorkflowDispatchRequest struct {
19 | Ref string `json:"ref"`
20 | Input map[string]any `json:"inputs"`
21 | }
22 |
23 | func (t *githubWorkflowDispatchRequest) SetInput(input string) error {
24 | if len(input) == 0 {
25 | return nil
26 | }
27 |
28 | return json.Unmarshal([]byte(input), &t.Input)
29 | }
30 |
31 | func (t *Github) Run(ctx context.Context, spec v1.GithubAction) (*GithubResponse, error) {
32 | token, err := ctx.GetEnvValueFromCache(spec.Token, ctx.GetNamespace())
33 | if err != nil {
34 | return nil, fmt.Errorf("could not get github token from env: %v", err)
35 | }
36 |
37 | var output GithubResponse
38 | for _, workflow := range spec.Workflows {
39 | postBody := githubWorkflowDispatchRequest{
40 | Ref: workflow.Ref,
41 | }
42 | if err := postBody.SetInput(workflow.Input); err != nil {
43 | return nil, fmt.Errorf("provided workflow input is not in JSON format: %s", workflow.Input)
44 | }
45 |
46 | endpoint := fmt.Sprintf("%s/%s/actions/workflows/%s/dispatches", spec.Username, spec.Repo, workflow.ID)
47 | response, err := http.NewClient().
48 | BaseURL("https://api.github.com/repos").
49 | Header("Accept", "application/vnd.github+json").
50 | Header("Authorization", fmt.Sprintf("Bearer %s", token)).
51 | Header("X-GitHub-Api-Version", "2022-11-28").
52 | R(ctx).Post(endpoint, postBody)
53 | if err != nil {
54 | return nil, err
55 | }
56 |
57 | if response.StatusCode != 204 {
58 | body, _ := response.AsString()
59 | return nil, fmt.Errorf("(workflow: %s) github api returned status code %d. %s", workflow.ID, response.StatusCode, body)
60 | }
61 | }
62 |
63 | return &output, nil
64 | }
65 |
--------------------------------------------------------------------------------
/playbook/actions/notification.go:
--------------------------------------------------------------------------------
1 | package actions
2 |
3 | import (
4 | "github.com/google/uuid"
5 |
6 | "github.com/flanksource/duty/context"
7 | v1 "github.com/flanksource/incident-commander/api/v1"
8 | "github.com/flanksource/incident-commander/notification"
9 | )
10 |
11 | // Notification runs the notification action
12 | type Notification struct {
13 | }
14 |
15 | type NotificationResult struct {
16 | Title string `json:"title,omitempty"`
17 | Message string `json:"message,omitempty"`
18 | Slack string `json:"slack,omitempty"`
19 | }
20 |
21 | func (t *Notification) Run(ctx context.Context, action v1.NotificationAction) (*NotificationResult, error) {
22 | notifContext := notification.NewContext(ctx, uuid.Nil)
23 | template := notification.NotificationTemplate{
24 | Title: action.Title,
25 | Message: action.Message,
26 | Properties: action.Properties,
27 | }
28 |
29 | service, err := notification.SendNotification(notifContext, action.Connection, action.URL, nil, template, nil)
30 | if err != nil {
31 | return nil, err
32 | }
33 |
34 | output := &NotificationResult{
35 | Title: template.Title,
36 | Message: template.Message,
37 | }
38 | if service == "slack" {
39 | output.Message = ""
40 | output.Slack = template.Message
41 | }
42 |
43 | return output, nil
44 | }
45 |
--------------------------------------------------------------------------------
/playbook/actions/suite_test.go:
--------------------------------------------------------------------------------
1 | package actions
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/flanksource/commons/logger"
8 | "github.com/fluxcd/pkg/gittestserver"
9 | ginkgo "github.com/onsi/ginkgo/v2"
10 | . "github.com/onsi/gomega"
11 | "github.com/onsi/gomega/format"
12 | "github.com/samber/oops"
13 | )
14 |
15 | var (
16 | gitServer *gittestserver.GitServer
17 | )
18 |
19 | func init() {
20 | format.RegisterCustomFormatter(func(value interface{}) (string, bool) {
21 |
22 | if e, ok := value.(error); ok {
23 | if err, ok := oops.AsOops(e); ok {
24 | return fmt.Sprintf("%+v", err), true
25 | }
26 | }
27 | return "", false
28 | })
29 | }
30 |
31 | func TestPlaybookActions(t *testing.T) {
32 | RegisterFailHandler(ginkgo.Fail)
33 | ginkgo.RunSpecs(t, "Playbook Action")
34 | }
35 |
36 | var _ = ginkgo.BeforeSuite(func() {
37 | var err error
38 | gitServer, err = gittestserver.NewTempGitServer()
39 | Expect(err).NotTo(HaveOccurred())
40 |
41 | logger.Infof("Git server started at: %s", gitServer.Root())
42 |
43 | go func() {
44 | defer ginkgo.GinkgoRecover() // Required by ginkgo, if an assertion is made in a goroutine.
45 | if err := gitServer.StartHTTP(); err != nil {
46 | ginkgo.Fail(fmt.Sprintf("Failed to start test server: %v", err))
47 | }
48 | }()
49 | })
50 |
51 | var _ = ginkgo.AfterSuite(func() {
52 | gitServer.StopHTTP()
53 | })
54 |
--------------------------------------------------------------------------------
/playbook/actions/testdata/dummy-repo/notification.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Notification
3 | metadata:
4 | name: http-check-passed
5 | spec:
6 | events:
7 | - check.passed
8 | filter: check.type == 'http'
9 | to:
10 | team: backend
11 |
--------------------------------------------------------------------------------
/playbook/pg_listeners.go:
--------------------------------------------------------------------------------
1 | package playbook
2 |
3 | import (
4 | "github.com/flanksource/duty/context"
5 | "github.com/flanksource/duty/postq/pg"
6 | )
7 |
8 | func ListenPlaybookPGNotify(ctx context.Context) {
9 | pgNotifyPlaybookSpecUpdated := make(chan string)
10 | go pg.Listen(ctx, "playbook_spec_updated", pgNotifyPlaybookSpecUpdated)
11 |
12 | for range pgNotifyPlaybookSpecUpdated {
13 | clearEventPlaybookCache()
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/playbook/runner/gitops.go:
--------------------------------------------------------------------------------
1 | package runner
2 |
3 | import (
4 | "github.com/flanksource/duty/context"
5 | "github.com/flanksource/duty/models"
6 | "github.com/flanksource/duty/query"
7 | v1 "github.com/flanksource/incident-commander/api/v1"
8 | )
9 |
10 | func getGitOpsTemplateVars(ctx context.Context, run models.PlaybookRun, actions []v1.PlaybookAction) (*query.GitOpsSource, error) {
11 | if run.ConfigID == nil {
12 | return nil, nil
13 | }
14 |
15 | var hasGitOpsAction bool
16 | for _, action := range actions {
17 | if action.GitOps != nil {
18 | hasGitOpsAction = true
19 | break
20 | }
21 | }
22 |
23 | if !hasGitOpsAction {
24 | return nil, nil
25 | }
26 |
27 | source, err := query.GetGitOpsSource(ctx, *run.ConfigID)
28 | return &source, err
29 | }
30 |
--------------------------------------------------------------------------------
/playbook/runner/longpoll.go:
--------------------------------------------------------------------------------
1 | package runner
2 |
3 | import (
4 | "encoding/json"
5 | "time"
6 |
7 | "github.com/flanksource/duty/postq/pg"
8 | )
9 |
10 | var DefaultLongpollTimeout = 45 * time.Second
11 |
12 | // Global instance
13 | var ActionNotifyRouter = pg.NewNotifyRouter().WithRouteExtractor(playbookActionNotifyRouteExtractor)
14 |
15 | type playbookActionNotifyPayload struct {
16 | ID string `json:"id"`
17 | AgentID string `json:"agent_id"`
18 | }
19 |
20 | func playbookActionNotifyRouteExtractor(payload string) (string, string, error) {
21 | var p playbookActionNotifyPayload
22 | if err := json.Unmarshal([]byte(payload), &p); err != nil {
23 | return "", "", err
24 | }
25 |
26 | route := p.AgentID
27 | extractedPayload := p.ID
28 |
29 | return route, extractedPayload, nil
30 | }
31 |
--------------------------------------------------------------------------------
/playbook/sdk/client.go:
--------------------------------------------------------------------------------
1 | package sdk
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/flanksource/commons/http"
7 | "github.com/samber/oops"
8 | )
9 |
10 | type PlaybookAPI struct {
11 | *http.Client
12 | }
13 |
14 | type RunResponse struct {
15 | RunID string `json:"run_id"`
16 | StartsAt string `json:"starts_at"`
17 | }
18 |
19 | type RunParams struct {
20 | ID any `json:"id"`
21 | ConfigID any `json:"config_id"`
22 | CheckID any `json:"check_id"`
23 | ComponentID any `json:"component_id"`
24 | Params map[string]string `json:"params"`
25 | }
26 |
27 | func NewPlaybookClient(base string) PlaybookAPI {
28 | return PlaybookAPI{
29 | Client: http.NewClient().BaseURL(base).
30 | Header("Content-Type", "application/json").
31 | UserAgent("mission-control/playbook"),
32 | }
33 | }
34 |
35 | func (api *PlaybookAPI) Run(params RunParams) (*RunResponse, error) {
36 | var response RunResponse
37 |
38 | r, err := api.R(context.Background()).Post("/playbook/run", params)
39 | if err != nil {
40 | return nil, err
41 | }
42 | // oops := oops.Request(r.RawRequest, true).Response(r.Response, true)
43 | oops := oops.Response(r.Response, true)
44 |
45 | if !r.IsOK() {
46 | json, err := r.AsJSON()
47 | if err != nil {
48 | return nil, oops.Wrap(err)
49 | }
50 | return nil, oops.Errorf(json["error"].(string))
51 | }
52 |
53 | return &response, oops.Wrap(r.Into(&response))
54 | }
55 |
--------------------------------------------------------------------------------
/playbook/test.properties:
--------------------------------------------------------------------------------
1 | log.level=info
2 | log.level.migrate=warn
3 | log.level.events=warn
4 | # db.log.level=trace
5 |
6 | access.log=true
7 | # access.log.request.body=true
8 | # access.log.response.body=true
9 | # access.log.response.headers=true
10 | # access.log.request.headers=true
11 |
12 | incidents.disabled=true
13 | http.client.trace=true
14 |
15 | playbook.runner.longpoll.timeout=2s
--------------------------------------------------------------------------------
/playbook/testdata/action-approvals.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=../../config/schemas/playbook.schema.json
2 |
3 | apiVersion: mission-control.flanksource.com/v1
4 | kind: Playbook
5 | metadata:
6 | name: action-approvals
7 | annotations:
8 | trace: "true"
9 | spec:
10 | description: write config name to file
11 | configs:
12 | - types:
13 | - EKS::Cluster
14 | labelSelector: environment=production
15 | parameters:
16 | - name: path
17 | label: path of the file
18 | required: true
19 | - name: name
20 | default: "{{.config.name}}"
21 | label: append this text to the file
22 | required: true
23 | actions:
24 | - name: write config id to a file
25 | timeout: 1s
26 | exec:
27 | script: echo id={{.config.id}} > {{.params.path}}
28 | - name: "append name to the same file "
29 | timeout: 2s
30 | exec:
31 | script: printf '{{.params.name}}' >> {{.params.path}}
32 | approval:
33 | type: any
34 | approvers:
35 | people:
36 | - john@doe.com
37 | - john@wick.com
38 |
--------------------------------------------------------------------------------
/playbook/testdata/action-check.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=../../config/schemas/playbook.schema.json
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: action-checks
6 | spec:
7 | description: write check name to file
8 | checks:
9 | - types:
10 | - http
11 | actions:
12 | - name: write check name to a file
13 | exec:
14 | script: printf {{.check.id}} > /tmp/{{.check.id}}.txt
15 |
--------------------------------------------------------------------------------
/playbook/testdata/action-component.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=../../config/schemas/playbook.schema.json
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: action-components
6 | spec:
7 | description: write component name to file
8 | components:
9 | - types:
10 | - Entity
11 | labelSelector: telemetry=enabled
12 | actions:
13 | - name: write component name to a file
14 | exec:
15 | script: echo name={{.component.name}} && printf {{.component.name}} > /tmp/{{.component.name}}.txt
16 |
--------------------------------------------------------------------------------
/playbook/testdata/action-exec-artifacts.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: exec-artifact
5 | spec:
6 | description: Simple script to generate an artifact
7 | configs:
8 | - types:
9 | - EC2 Instance
10 | labelSelector: "telemetry=enabled"
11 | actions:
12 | - name: 'Generate artifact'
13 | exec:
14 | script: echo "hello world"
15 | artifacts:
16 | - path: /dev/stdout
17 |
18 |
--------------------------------------------------------------------------------
/playbook/testdata/action-filter.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=../../config/schemas/playbook.schema.json
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: action-filter
6 | spec:
7 | parameters:
8 | - name: path
9 | label: Path of the file
10 | - name: log_path
11 | label: Path of the log file
12 | actions:
13 | - name: Create the file
14 | exec:
15 | script: echo -n '{{.config.config_class}}' > {{.params.path}}
16 | - name: Log if the file creation failed
17 | if: "failure()"
18 | exec:
19 | script: echo 'File creation failed' > {{.params.log_path}}
20 | - name: Log if the file creation succeeded
21 | if: success()
22 | exec:
23 | script: echo 'File creation succeeded' > {{.params.log_path}}
24 | - name: Run a non existing command
25 | exec:
26 | script: my-perfect-command start
27 | - name: Log if the command failed
28 | if: "failure()"
29 | exec:
30 | script: echo 'Command failed' >> {{.params.log_path}}
31 | - name: "Skip if cluster config"
32 | if: 'config.config_class != "Cluster" ? true: false'
33 | exec:
34 | script: echo 'Config is not a cluster' >> {{.params.log_path}}
35 | - name: "Log the end of the playbook"
36 | if: "always()"
37 | exec:
38 | script: echo '==end==' >> {{.params.log_path}}
39 |
--------------------------------------------------------------------------------
/playbook/testdata/action-http-authorized.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=../../config/schemas/playbook.schema.json
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | uid: d9e3a32d-e9c7-470f-a8a6-60197730d8c8
6 | name: http-authorized
7 | namespace: mc
8 | spec:
9 | configs:
10 | - types:
11 | - Kubernetes::Pod
12 | actions:
13 | - name: HTTP
14 | http:
15 | connection: connection://mc/httpbin
16 |
--------------------------------------------------------------------------------
/playbook/testdata/action-http-unauthorized.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=../../config/schemas/playbook.schema.json
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: http-unauthorized
6 | namespace: mc
7 | spec:
8 | configs:
9 | - types:
10 | - Kubernetes::Pod
11 | actions:
12 | - name: HTTP
13 | http:
14 | url: https://httpbin.org/get
15 | connection: connection://mc/httpbin
16 |
--------------------------------------------------------------------------------
/playbook/testdata/action-last-result.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=../../config/schemas/playbook.schema.json
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: action-last-result
6 | spec:
7 | parameters:
8 | - name: path
9 | label: Path to save the result
10 | actions:
11 | - name: make dummy API call
12 | exec:
13 | script: |
14 | echo '{
15 | "result": "success",
16 | "count": 20
17 | }'
18 | - name: Notify if the count is low
19 | if: "getLastAction().result.stdout.JSON().count < 5"
20 | notification:
21 | title: "Count is low"
22 | message: Count is low
23 | connection: connection://slack/flanksource
24 | - name: Log if count is high
25 | if: 'getAction("make dummy API call").result.stdout.JSON().count > 15'
26 | exec:
27 | script: echo 'HIGH' > {{.params.path}}
28 | - name: Save the count
29 | if: "success()"
30 | exec:
31 | script: |
32 | {{$result:=index (getAction "make dummy API call") "result"}}
33 | {{$stdout:=index $result "stdout"}}
34 | echo -n '{{index ($stdout | json) "count"}}' >> {{.params.path}}
35 |
--------------------------------------------------------------------------------
/playbook/testdata/action-params.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=../../config/schemas/playbook.schema.json
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: action-params
6 | spec:
7 | parameters:
8 | - name: path
9 | label: The path
10 | - name: my_config
11 | label: The config
12 | type: config
13 | - name: my_component
14 | label: The component
15 | type: component
16 | actions:
17 | - name: write config name to the file
18 | exec:
19 | script: echo {{.params.my_config.config_class}} > {{.params.path}}
20 | - name: "append component name to the same file "
21 | exec:
22 | script: echo {{.params.my_component.name}} >> {{.params.path}}
23 |
--------------------------------------------------------------------------------
/playbook/testdata/agent-runner.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=../../config/schemas/playbook.schema.json
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: agent-runner
6 | spec:
7 | runsOn:
8 | - local
9 | - aws
10 | - azure
11 | description: Write a file to the node
12 | configs:
13 | - types:
14 | - Kubernetes::Node
15 | actions:
16 | - name: Echo class on the host
17 | exec:
18 | script: |
19 | echo "class from local agent: {{.config.config_class}}"
20 | - name: Echo class on agent aws
21 | runsOn:
22 | - 'aws'
23 | templatesOn: agent
24 | exec:
25 | script: |
26 | echo "class from aws agent: {{.config.config_class}}"
27 | - name: Echo class on agent azure
28 | runsOn:
29 | - 'azure'
30 | templatesOn: agent
31 | exec:
32 | script: |
33 | echo "class from azure agent: {{.config.config_class}}"
34 |
--------------------------------------------------------------------------------
/playbook/testdata/bad-action-spec.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=../../config/schemas/playbook.schema.json
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: bad-action-sepc
6 | spec:
7 | actions:
8 | - name: Create the file
9 | exec:
10 | script: echo -n '{{bad-template}}' > {{.params.path}}
11 |
--------------------------------------------------------------------------------
/playbook/testdata/bad-spec.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=../../config/schemas/playbook.schema.json
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: bad-spec
6 | spec:
7 | actions:
8 | - name: Create the file
9 | delay: "bad delay expression"
10 | exec:
11 | script: echo -n '{{.config.config_class}}' > {{.params.path}}
12 |
--------------------------------------------------------------------------------
/playbook/testdata/connections/artifact.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Connection
3 | metadata:
4 | name: artifacts
5 | namespace: default
6 | uid: 7a195fca-de26-4711-9c6f-fff50c7e6672
7 | spec:
8 | folder:
9 | path: .artifacts
--------------------------------------------------------------------------------
/playbook/testdata/connections/gemini.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Connection
3 | metadata:
4 | name: gemini
5 | namespace: default
6 | uid: 135dc5b8-7216-4f2f-8683-0b4b728738fc
7 | spec:
8 | gemini:
9 | apiKey:
10 | value: GEMINI_API_KEY_PLACEHOLDER
11 | model: gemini-2.0-flash
--------------------------------------------------------------------------------
/playbook/testdata/connections/httpbin.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Connection
3 | metadata:
4 | name: httpbin
5 | namespace: mc
6 | uid: 7eb7d4c8-faad-4809-9643-e8116eef6e3e
7 | spec:
8 | url:
9 | value: https://httpbin.org/status/200
--------------------------------------------------------------------------------
/playbook/testdata/e2e/loki.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: loki-logs
5 | namespace: default
6 | annotations:
7 | expected-payment-processor: |
8 | ERROR Credit card validation failed: invalid card number
9 | ERROR Failed to process payment for order: order-456789
10 | ERROR Payment gateway timeout for transaction: txn-abc123
11 | expected-api-gateway: |
12 | INFO Health check completed successfully
13 | INFO Received GET request for /api/health
14 | INFO Starting API Gateway server on port 8080
15 | spec:
16 | title: Loki Logs
17 | icon: logs
18 | category: Logs
19 | description: Fetch logs from Loki
20 | configs:
21 | - types:
22 | - Kubernetes::Pod
23 | - Kubernetes::Deployment
24 | parameters:
25 | - name: limit
26 | label: Limit
27 | description: The maximum number of logs to fetch
28 | required: false
29 | default: "100"
30 | actions:
31 | - name: payment-processor
32 | logs:
33 | loki:
34 | url: http://localhost:3100
35 | query: >
36 | {service="payment-processor",level="error"}
37 | limit: $(.params.limit)
38 | start: now-2h
39 | - name: api-gateway
40 | logs:
41 | loki:
42 | url: http://localhost:3100
43 | query: >
44 | {service="api-gateway"}
45 | limit: $(.params.limit)
46 | start: now-2h
47 |
48 |
--------------------------------------------------------------------------------
/playbook/testdata/e2e/opensearch.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: opensearch-logs
5 | namespace: default
6 | annotations:
7 | expected-frontend-logs: |
8 | React app started successfully
9 | User authentication successful for user: john.doe@example.com
10 | spec:
11 | title: OpenSearch Logs
12 | icon: elasticsearch
13 | category: Logs
14 | description: Fetch logs from OpenSearch
15 | configs:
16 | - types:
17 | - Kubernetes::Pod
18 | - Kubernetes::Deployment
19 | parameters:
20 | - name: limit
21 | label: Limit
22 | description: The maximum number of logs to fetch
23 | required: false
24 | default: "100"
25 | actions:
26 | - name: frontend-logs
27 | logs:
28 | opensearch:
29 | address: http://localhost:9200
30 | query: |
31 | {
32 | "query": {
33 | "bool": {
34 | "filter": [
35 | { "term": { "kubernetes.labels.app": "frontend" } }
36 | ]
37 | }
38 | }
39 | }
40 | index: k8s-logs
41 | limit: $(.params.limit)
42 |
--------------------------------------------------------------------------------
/playbook/testdata/echo.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Playbook
3 | metadata:
4 | name: echo-simple
5 | spec:
6 | configs:
7 | - types:
8 | - Kubernetes::Node
9 | actions:
10 | - name: echo
11 | exec:
12 | script: echo "simple"
13 |
--------------------------------------------------------------------------------
/playbook/testdata/exec-connection-kubernetes.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: kubernetes-connection-from-scraper
6 | namespace: mc
7 | spec:
8 | category: Echoer
9 | description: list kubeconfig env var
10 | configs:
11 | - types:
12 | - Kubernetes::Deployment
13 | actions:
14 | - name: echo
15 | exec:
16 | connections:
17 | fromConfigItem: "{{.config.id}}"
18 | script: "echo $KUBECONFIG"
19 |
--------------------------------------------------------------------------------
/playbook/testdata/exec-powershell.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=../../config/schemas/playbook.schema.json
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: exec-powershell
6 | spec:
7 | configs:
8 | - types:
9 | - Kubernetes::Pod
10 | actions:
11 | - name: Powershell
12 | exec:
13 | script: |
14 | #! pwsh
15 | @{item="{{.config.name}}"} | ConvertTo-JSON
16 | - name: delims
17 | if: always()
18 | exec:
19 | script: |
20 | #! pwsh
21 | # gotemplate: left-delim=$[[ right-delim=]]
22 | $message = "name=$[[.config.name]]"
23 | @{item=$message} | ConvertTo-JSON
24 |
--------------------------------------------------------------------------------
/playbook/testdata/my-kube-config.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | clusters:
3 | - cluster:
4 | server: https://10.99.99.222:6443
5 | name: default
6 | contexts:
7 | - context:
8 | cluster: default
9 | user: default
10 | name: default
11 | current-context: default
12 | kind: Config
13 | preferences: {}
14 | users:
15 | - name: default
16 | user:
17 | client-certificate-data:
18 | client-key-data:
19 |
--------------------------------------------------------------------------------
/playbook/testdata/permissions/allow-ai-artifacts-connection.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Permission
3 | metadata:
4 | name: allow-playbook-artifacts
5 | spec:
6 | description: allow playbook default/diagnose-resource to read connection default/artifacts
7 | subject:
8 | playbook: default/diagnose-resource
9 | actions:
10 | - read
11 | object:
12 | connections:
13 | - name: artifacts
14 | namespace: default
15 |
--------------------------------------------------------------------------------
/playbook/testdata/permissions/allow-ai-gemini-connection.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Permission
3 | metadata:
4 | name: allow-playbook-artifacts
5 | spec:
6 | description: allow playbook default/diagnose-resource to read connection default/gemini
7 | subject:
8 | playbook: default/diagnose-resource
9 | actions:
10 | - read
11 | object:
12 | connections:
13 | - name: gemini
14 | namespace: default
15 |
--------------------------------------------------------------------------------
/playbook/testdata/permissions/allow-config-read.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Permission
3 | metadata:
4 | name: rbac-john-catalog-read
5 | spec:
6 | description: Grant John read permissions to catalogs
7 | subject:
8 | person: john@doe.com
9 | actions:
10 | - read
11 | object:
12 | configs:
13 | - name: "*"
14 |
--------------------------------------------------------------------------------
/playbook/testdata/permissions/allow-exec-playbook-artifact.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Permission
3 | metadata:
4 | name: allow-playbook-artifacts
5 | spec:
6 | description: allow playbook default/exec-artifact to read connection default/artifacts
7 | subject:
8 | playbook: default/exec-artifact
9 | actions:
10 | - read
11 | object:
12 | connections:
13 | - name: artifacts
14 | namespace: default
15 |
--------------------------------------------------------------------------------
/playbook/testdata/permissions/allow-john-connection.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Permission
3 | metadata:
4 | name: allow-playbook-connection
5 | spec:
6 | description: allow john to read connection mc/httpbin
7 | subject:
8 | person: john@doe.com
9 | actions:
10 | - read
11 | object:
12 | connections:
13 | - name: httpbin
14 | namespace: mc
15 |
--------------------------------------------------------------------------------
/playbook/testdata/permissions/allow-loki-logs-artifact.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Permission
3 | metadata:
4 | name: allow-loki-logs-artifacts
5 | spec:
6 | description: allow loki-logs to read connection default/artifacts
7 | subject:
8 | playbook: default/loki-logs
9 | actions:
10 | - read
11 | object:
12 | connections:
13 | - name: artifacts
14 | namespace: default
15 |
--------------------------------------------------------------------------------
/playbook/testdata/permissions/allow-opensearch-logs-artifact.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Permission
3 | metadata:
4 | name: allow-opensearch-logs-artifacts
5 | spec:
6 | description: allow opensearch-logs to read connection default/artifacts
7 | subject:
8 | playbook: default/opensearch-logs
9 | actions:
10 | - read
11 | object:
12 | connections:
13 | - name: artifacts
14 | namespace: default
15 |
--------------------------------------------------------------------------------
/playbook/testdata/permissions/allow-playbook-connection.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Permission
3 | metadata:
4 | name: allow-playbook-connection
5 | spec:
6 | description: allow playbook mc/http-authorized to read connection mc/httpbin
7 | subject:
8 | playbook: mc/http-authorized
9 | actions:
10 | - read
11 | object:
12 | connections:
13 | - name: httpbin
14 | namespace: mc
15 |
--------------------------------------------------------------------------------
/playbook/testdata/permissions/allow-playbook.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Permission
3 | metadata:
4 | name: allow-john-playbook-run
5 | spec:
6 | description: allow user john to run any playbook on configs with tags cluster = demo or aws
7 | subject:
8 | person: john@doe.com
9 | actions:
10 | - playbook:*
11 | object:
12 | playbooks:
13 | - name: "*" # this is a wildcard selector that matches any playbook
14 | configs:
15 | - tagSelector: cluster=aws # Allow running any playbook on configs with tag cluster=aws
16 | - tagSelector: cluster=demo
17 |
--------------------------------------------------------------------------------
/playbook/testdata/permissions/allow-retry-playbook.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Permission
3 | metadata:
4 | name: allow-john-retyr-echo-playbook-run
5 | spec:
6 | description: allow user john to run retry-echo playbook on any config
7 | subject:
8 | person: john@doe.com
9 | actions:
10 | - playbook:*
11 | object:
12 | playbooks:
13 | - name: retry-echo
--------------------------------------------------------------------------------
/playbook/testdata/permissions/deny-echo-playbook.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Permission
3 | metadata:
4 | name: deny-user-john-playbook-run
5 | spec:
6 | description: deny user john from running
7 | subject:
8 | person: john@doe.com
9 | actions:
10 | - playbook:*
11 | deny: true
12 | object:
13 | playbooks:
14 | - name: echo-simple
15 | configs:
16 | - name: "*"
--------------------------------------------------------------------------------
/playbook/testdata/permissions/deny-playbook-on-config.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: mission-control.flanksource.com/v1
2 | kind: Permission
3 | metadata:
4 | name: deny-john-node-read
5 | spec:
6 | description: Disallow John to read Node catalogs from us-west-2
7 | subject:
8 | person: john@doe.com
9 | deny: true
10 | actions:
11 | - playbook:run
12 | object:
13 | playbooks:
14 | - name: "*"
15 | configs:
16 | - tagSelector: cluster=aws,account=flanksource
17 | name: node-b
18 | types:
19 | - Kubernetes::Node
20 |
--------------------------------------------------------------------------------
/playbook/testdata/retries.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=../../config/schemas/playbook.schema.json
2 | apiVersion: mission-control.flanksource.com/v1
3 | kind: Playbook
4 | metadata:
5 | name: retry-echo
6 | spec:
7 | actions:
8 | - name: echo
9 | retry:
10 | limit: 2
11 | duration: 2s
12 | jitter: 0
13 | exponent:
14 | multiplier: 2
15 | exec:
16 | script: bad-binary
17 |
--------------------------------------------------------------------------------
/playbook/testdata/seed.go:
--------------------------------------------------------------------------------
1 | package testdata
2 |
3 | import (
4 | "embed"
5 | "path/filepath"
6 |
7 | "github.com/flanksource/duty/context"
8 | "github.com/flanksource/duty/rbac"
9 |
10 | v1 "github.com/flanksource/incident-commander/api/v1"
11 | "github.com/flanksource/incident-commander/db"
12 | "github.com/google/uuid"
13 | "k8s.io/apimachinery/pkg/types"
14 |
15 | yamlutil "k8s.io/apimachinery/pkg/util/yaml"
16 | )
17 |
18 | //go:embed connections
19 | var connectionDir embed.FS
20 |
21 | //go:embed permissions
22 | var permissionsDir embed.FS
23 |
24 | func LoadPermissions(ctx context.Context) error {
25 | entries, err := permissionsDir.ReadDir("permissions")
26 | if err != nil {
27 | return err
28 | }
29 |
30 | for _, entry := range entries {
31 | path := filepath.Join("permissions", entry.Name())
32 | content, err := permissionsDir.ReadFile(path)
33 | if err != nil {
34 | return err
35 | }
36 |
37 | var perm v1.Permission
38 | err = yamlutil.Unmarshal(content, &perm)
39 | if err != nil {
40 | return err
41 | }
42 |
43 | perm.UID = types.UID(uuid.New().String())
44 |
45 | err = db.PersistPermissionFromCRD(ctx, &perm)
46 | if err != nil {
47 | return err
48 | }
49 | }
50 |
51 | err = rbac.ReloadPolicy()
52 | if err != nil {
53 | return err
54 | }
55 |
56 | return nil
57 | }
58 |
59 | func LoadConnections(ctx context.Context) error {
60 | entries, err := connectionDir.ReadDir("connections")
61 | if err != nil {
62 | return err
63 | }
64 |
65 | for _, entry := range entries {
66 | path := filepath.Join("connections", entry.Name())
67 | content, err := connectionDir.ReadFile(path)
68 | if err != nil {
69 | return err
70 | }
71 |
72 | var conn v1.Connection
73 | err = yamlutil.Unmarshal(content, &conn)
74 | if err != nil {
75 | return err
76 | }
77 |
78 | err = db.PersistConnectionFromCRD(ctx, &conn)
79 | if err != nil {
80 | return err
81 | }
82 | }
83 |
84 | return nil
85 | }
86 |
--------------------------------------------------------------------------------
/push/suite_test.go:
--------------------------------------------------------------------------------
1 | package push
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/flanksource/duty/context"
7 | "github.com/flanksource/duty/shutdown"
8 | "github.com/flanksource/duty/tests/setup"
9 | "github.com/labstack/echo/v4"
10 |
11 | ginkgo "github.com/onsi/ginkgo/v2"
12 | . "github.com/onsi/gomega"
13 | )
14 |
15 | var (
16 | DefaultContext context.Context
17 | PushServerContext context.Context
18 |
19 | echoServerPort int
20 | )
21 |
22 | func TestPush(t *testing.T) {
23 | RegisterFailHandler(ginkgo.Fail)
24 | ginkgo.RunSpecs(t, "Push")
25 | }
26 |
27 | var _ = ginkgo.BeforeSuite(func() {
28 | DefaultContext = setup.BeforeSuiteFn(setup.WithoutDummyData)
29 |
30 | if context, drop, err := setup.NewDB(DefaultContext, "push_server"); err != nil {
31 | ginkgo.Fail(err.Error())
32 | } else {
33 | PushServerContext = *context
34 | shutdown.AddHookWithPriority("db drop", shutdown.PriorityCritical, drop)
35 | }
36 |
37 | e := echo.New()
38 | e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
39 | return func(c echo.Context) error {
40 | c.SetRequest(c.Request().WithContext(PushServerContext.Wrap(c.Request().Context())))
41 | return next(c)
42 | }
43 | })
44 | e.POST("/push/topology", PushTopology)
45 |
46 | var shutdownEcho func()
47 | echoServerPort, shutdownEcho = setup.RunEcho(e)
48 | shutdown.AddHookWithPriority("shutdown Echo server", shutdown.PriorityCritical, shutdownEcho)
49 | })
50 |
51 | var _ = ginkgo.AfterSuite(func() {
52 | setup.AfterSuiteFn()
53 | })
54 |
--------------------------------------------------------------------------------
/rbac/controllers.go:
--------------------------------------------------------------------------------
1 | package rbac
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/flanksource/duty/api"
7 | "github.com/flanksource/duty/rbac"
8 | "github.com/flanksource/duty/rbac/policy"
9 | "github.com/labstack/echo/v4"
10 | )
11 |
12 | func UpdateRoleForUser(c echo.Context) error {
13 | userID := c.Param("id")
14 | reqData := struct {
15 | Roles []string `json:"roles"`
16 | }{}
17 | if err := c.Bind(&reqData); err != nil {
18 | return c.JSON(http.StatusBadRequest, api.HTTPError{
19 | Err: err.Error(),
20 | Message: "Invalid request body",
21 | })
22 | }
23 |
24 | if err := rbac.AddRoleForUser(userID, reqData.Roles...); err != nil {
25 | return c.JSON(http.StatusInternalServerError, api.HTTPError{
26 | Err: err.Error(),
27 | Message: "Error updating roles",
28 | })
29 | }
30 |
31 | return c.JSON(http.StatusOK, api.HTTPSuccess{
32 | Message: "Role updated successfully",
33 | })
34 | }
35 |
36 | func GetRolesForUser(c echo.Context) error {
37 | userID := c.Param("id")
38 | roles, err := rbac.RolesForUser(userID)
39 | if err != nil {
40 | return c.JSON(http.StatusInternalServerError, api.HTTPError{
41 | Err: err.Error(),
42 | Message: "Error getting roles",
43 | })
44 | }
45 | return c.JSON(http.StatusOK, api.HTTPSuccess{
46 | Payload: roles,
47 | })
48 | }
49 |
50 | func Dump(c echo.Context) error {
51 | perms, err := rbac.Enforcer().GetPolicy()
52 | if err != nil {
53 | return err
54 | }
55 |
56 | return c.JSON(http.StatusOK, policy.NewPermissions(perms))
57 | }
58 |
--------------------------------------------------------------------------------
/snapshot/writer.go:
--------------------------------------------------------------------------------
1 | package snapshot
2 |
3 | import (
4 | "encoding/csv"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "strings"
9 | )
10 |
11 | func writeToCSVFile(pathPrefix, name string, headerRow string, dbRows []map[string]any) error {
12 | f, err := os.Create(filepath.Join(pathPrefix, name))
13 | if err != nil {
14 | return err
15 | }
16 |
17 | var rows [][]string
18 | rows = append(rows, strings.Split(headerRow, ","))
19 | for _, row := range dbRows {
20 | var columns []string
21 | for _, c := range row {
22 | columns = append(columns, fmt.Sprint(c))
23 | }
24 | rows = append(rows, columns)
25 | }
26 | w := csv.NewWriter(f)
27 | return w.WriteAll(rows)
28 | }
29 |
30 | func writeToJSONFile(pathPrefix, name string, data []byte) error {
31 | if len(data) == 0 {
32 | return nil
33 | }
34 |
35 | return os.WriteFile(filepath.Join(pathPrefix, name), data, 0644)
36 | }
37 |
38 | func writeToLogFile(pathPrefix, name string, logs []byte) error {
39 | if len(logs) == 0 {
40 | return nil
41 | }
42 | return os.WriteFile(filepath.Join(pathPrefix, name), logs, 0644)
43 | }
44 |
--------------------------------------------------------------------------------
/tests/e2e/setup/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | loki:
3 | image: grafana/loki:3.0.0
4 | ports: ["3100:3100"]
5 | command: -config.file=/etc/loki/local-config.yaml
6 |
7 | opensearch:
8 | container_name: opensearch
9 | image: opensearchproject/opensearch:3
10 | environment:
11 | - discovery.type=single-node
12 | - DISABLE_INSTALL_DEMO_CONFIG=true
13 | - DISABLE_SECURITY_PLUGIN=true
14 | ports: ["9200:9200"]
15 |
16 | # localstack:
17 | # image: localstack/localstack:3
18 | # environment:
19 | # - SERVICES=cloudwatch,logs
20 | # ports: ["4566:4566"]
21 |
22 | # kindctl: # optional helper; KinD cluster spins up in the job step
23 | # image: docker
24 | # command: ["true"] # placeholder, no container running during tests
25 |
--------------------------------------------------------------------------------
/tests/e2e/setup/seed-loki.json:
--------------------------------------------------------------------------------
1 | {
2 | "streams": [
3 | {
4 | "stream": {
5 | "service": "api-gateway",
6 | "level": "info",
7 | "environment": "production"
8 | },
9 | "values": [
10 | [
11 | "{{TIMESTAMP_1}}",
12 | "INFO Starting API Gateway server on port 8080"
13 | ],
14 | ["{{TIMESTAMP_2}}", "INFO Received GET request for /api/health"],
15 | ["{{TIMESTAMP_3}}", "INFO Health check completed successfully"]
16 | ]
17 | },
18 | {
19 | "stream": {
20 | "service": "payment-processor",
21 | "level": "error",
22 | "environment": "production"
23 | },
24 | "values": [
25 | [
26 | "{{TIMESTAMP_1}}",
27 | "ERROR Payment gateway timeout for transaction: txn-abc123"
28 | ],
29 | [
30 | "{{TIMESTAMP_2}}",
31 | "ERROR Failed to process payment for order: order-456789"
32 | ],
33 | [
34 | "{{TIMESTAMP_3}}",
35 | "ERROR Credit card validation failed: invalid card number"
36 | ]
37 | ]
38 | },
39 | {
40 | "stream": {
41 | "service": "payment-processor",
42 | "level": "info",
43 | "environment": "production"
44 | },
45 | "values": [
46 | ["{{TIMESTAMP_2}}", "INFO Payment successful: txn-123"],
47 | ["{{TIMESTAMP_3}}", "INFO Payment successful: txn-456"]
48 | ]
49 | }
50 | ]
51 | }
52 |
--------------------------------------------------------------------------------
/tests/fixtures_schema_validate_test.go:
--------------------------------------------------------------------------------
1 | package tests
2 |
3 | import (
4 | "fmt"
5 | "io/fs"
6 | "os"
7 | "path/filepath"
8 | "strings"
9 |
10 | . "github.com/onsi/ginkgo/v2"
11 | . "github.com/onsi/gomega"
12 | "github.com/santhosh-tekuri/jsonschema/v6"
13 | "gopkg.in/yaml.v3"
14 | )
15 |
16 | func validateFixtureDirWithSchema(schemaPath, dir string) {
17 | schema, err := jsonschema.NewCompiler().Compile(schemaPath)
18 | Expect(err).To(BeNil())
19 |
20 | err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
21 | if strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml") {
22 | yamlRaw, err := os.ReadFile(path)
23 | if err != nil {
24 | return err
25 | }
26 |
27 | var m any
28 | err = yaml.Unmarshal(yamlRaw, &m)
29 | if err != nil {
30 | return err
31 | }
32 |
33 | if err := schema.Validate(m); err != nil {
34 | return fmt.Errorf("schema validation failed for %s: %w", path, err)
35 | }
36 | }
37 | return nil
38 | })
39 | Expect(err).To(BeNil())
40 | }
41 |
42 | var _ = Describe("Fixture schema validation", func() {
43 | It("Notifications", func() {
44 | schemaPath := "../config/schemas/notification.schema.json"
45 | validateFixtureDirWithSchema(schemaPath, "../fixtures/notifications/")
46 | })
47 |
48 | It("NotificationSilence", func() {
49 | schemaPath := "../config/schemas/notificationsilence.schema.json"
50 | validateFixtureDirWithSchema(schemaPath, "../fixtures/silences/")
51 | })
52 |
53 | It("Playbooks", func() {
54 | schemaPath := "../config/schemas/playbook.schema.json"
55 | validateFixtureDirWithSchema(schemaPath, "../fixtures/playbooks/")
56 | })
57 |
58 | It("Rules", func() {
59 | schemaPath := "../config/schemas/incident-rules.schema.json"
60 | validateFixtureDirWithSchema(schemaPath, "../fixtures/rules")
61 | })
62 | })
63 |
--------------------------------------------------------------------------------
/utils/bytes.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | func Tail(data []byte, size int) []byte {
4 | if len(data) <= size {
5 | return data
6 | }
7 |
8 | return data[len(data)-size:]
9 | }
10 |
--------------------------------------------------------------------------------
/utils/bytes_test.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "testing"
5 |
6 | . "github.com/onsi/gomega"
7 | )
8 |
9 | func TestTail(t *testing.T) {
10 | tests := []struct {
11 | name string
12 | data []byte
13 | size int
14 | expected []byte
15 | }{
16 | {
17 | name: "empty slice",
18 | data: []byte{},
19 | size: 5,
20 | expected: []byte{},
21 | },
22 | {
23 | name: "size larger than data",
24 | data: []byte{1, 2, 3},
25 | size: 5,
26 | expected: []byte{1, 2, 3},
27 | },
28 | {
29 | name: "size equal to data length",
30 | data: []byte{1, 2, 3},
31 | size: 3,
32 | expected: []byte{1, 2, 3},
33 | },
34 | {
35 | name: "size smaller than data",
36 | data: []byte{1, 2, 3, 4, 5},
37 | size: 3,
38 | expected: []byte{3, 4, 5},
39 | },
40 | {
41 | name: "size zero",
42 | data: []byte{1, 2, 3},
43 | size: 0,
44 | expected: []byte{},
45 | },
46 | }
47 |
48 | for _, tt := range tests {
49 | t.Run(tt.name, func(t *testing.T) {
50 | g := NewGomegaWithT(t)
51 | result := Tail(tt.data, tt.size)
52 | g.Expect(result).To(Equal(tt.expected))
53 | })
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/utils/dir.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "path/filepath"
7 | )
8 |
9 | // CreateTempSubdir creates a temporary directory in the current working directory.
10 | //
11 | // It's helpful when we need to create a temp dir in a relative path
12 | // as it returns the absolute path to the temp dir.
13 | func CreateTempSubdir(base string, pattern string) (string, error) {
14 | wd, err := os.Getwd()
15 | if err != nil {
16 | return "", fmt.Errorf("failed to get current working directory: %w", err)
17 | }
18 |
19 | baseDir := filepath.Join(wd, base)
20 | if err := os.MkdirAll(baseDir, 0755); err != nil {
21 | return "", fmt.Errorf("failed to create %s directory: %w", base, err)
22 | }
23 |
24 | dir, err := os.MkdirTemp(baseDir, pattern)
25 | if err != nil {
26 | return "", fmt.Errorf("failed to create temporary directory: %w", err)
27 | }
28 |
29 | return dir, nil
30 | }
31 |
--------------------------------------------------------------------------------
/utils/error.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "github.com/samber/oops"
4 |
5 | func MatchOopsErrCode(err error, code string) bool {
6 | if err == nil {
7 | return false
8 | }
9 |
10 | oe, ok := oops.AsOops(err)
11 | if !ok {
12 | return false
13 | }
14 |
15 | return oe.Code() == code
16 | }
17 |
--------------------------------------------------------------------------------
/utils/http.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "embed"
5 | "io/fs"
6 | "net/http"
7 | )
8 |
9 | func HTTPFileserver(embeddedFS embed.FS) (http.Handler, error) {
10 | fsys, err := fs.Sub(embeddedFS, ".")
11 | if err != nil {
12 | return nil, err
13 | }
14 | return http.FileServer(http.FS(fsys)), nil
15 | }
16 |
--------------------------------------------------------------------------------
/utils/map.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "sync"
7 |
8 | "github.com/samber/lo"
9 | )
10 |
11 | type SyncedMap[K comparable, V any] struct {
12 | mu sync.RWMutex
13 | m map[K][]V
14 | }
15 |
16 | func (m *SyncedMap[K, V]) Get(key K) []V {
17 | m.mu.RLock()
18 | defer m.mu.RUnlock()
19 | return m.m[key]
20 | }
21 |
22 | func (m *SyncedMap[K, V]) Keys() []K {
23 | m.mu.RLock()
24 | defer m.mu.RUnlock()
25 | return lo.Keys(m.m)
26 | }
27 |
28 | func (m *SyncedMap[K, V]) Each(fn func(K, []V)) {
29 | m.mu.RLock()
30 | defer m.mu.RUnlock()
31 | for k, v := range m.m {
32 | fn(k, v)
33 | }
34 | }
35 |
36 | func (m *SyncedMap[K, V]) Append(key K, value V) {
37 | m.mu.Lock()
38 | if m.m == nil {
39 | m.m = make(map[K][]V)
40 | }
41 | if m.m[key] == nil {
42 | m.m[key] = []V{}
43 | }
44 | m.m[key] = append(m.m[key], value)
45 | m.mu.Unlock()
46 | }
47 |
48 | // StringMapToString converts a map[string]string to a string
49 | func StringMapToString(m map[string]string) string {
50 | if m == nil {
51 | return "{}"
52 | }
53 |
54 | b, _ := json.Marshal(m)
55 | return string(b)
56 | }
57 |
58 | // StringToStringMap converts a string back to a map[string]string
59 | func StringToStringMap(s string) (map[string]string, error) {
60 | m := make(map[string]string)
61 | if s == "" {
62 | return m, nil
63 | }
64 |
65 | err := json.Unmarshal([]byte(s), &m)
66 | if err != nil {
67 | return nil, fmt.Errorf("error unmarshaling string: %w", err)
68 | }
69 |
70 | return m, nil
71 | }
72 |
--------------------------------------------------------------------------------
/vars/vars.go:
--------------------------------------------------------------------------------
1 | package vars
2 |
3 | import "time"
4 |
5 | var AuthMode = ""
6 |
7 | const (
8 | // RLS Flag should be set explicitly to avoid unwanted DB Locks
9 | FlagRLSEnable = "rls.enable"
10 | FlagRLSDisable = "rls.disable"
11 | )
12 |
13 | const PlaybookRunTimeout = 30 * time.Minute
14 |
--------------------------------------------------------------------------------