├── .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 | --------------------------------------------------------------------------------