diff --git a/docs/openstatus-docs.md b/docs/openstatus-docs.md index 912f49e..e1e2bb8 100644 --- a/docs/openstatus-docs.md +++ b/docs/openstatus-docs.md @@ -558,3 +558,32 @@ Usage: ```bash $ openstatus [GLOBAL FLAGS] logout [ARGUMENTS...] ``` + +### `terraform` command (aliases: `tf`) + +Generate Terraform configuration. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] terraform [ARGUMENTS...] +``` + +### `terraform generate` subcommand (aliases: `gen`) + +Generate Terraform configuration from workspace resources. + +> openstatus terraform generate [--output-dir ./openstatus-terraform/] + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] terraform generate [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Default value | Environment variables | +|-----------------------------|-----------------------------------------|:-------------------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | | `OPENSTATUS_API_TOKEN` | +| `--output-dir="…"` (`-o`) | Directory to write Terraform files into | `./openstatus-terraform/` | *none* | diff --git a/docs/openstatus.1 b/docs/openstatus.1 index 995903f..2dad6ef 100644 --- a/docs/openstatus.1 +++ b/docs/openstatus.1 @@ -1346,3 +1346,57 @@ Usage: .EX $ openstatus [GLOBAL FLAGS] logout [ARGUMENTS...] .EE +.SS \f[CR]terraform\f[R] command (aliases: \f[CR]tf\f[R]) +Generate Terraform configuration. +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] terraform [ARGUMENTS...] +.EE +.SS \f[CR]terraform generate\f[R] subcommand (aliases: \f[CR]gen\f[R]) +Generate Terraform configuration from workspace resources. +.RS +.PP +openstatus terraform generate [\(enoutput\-dir ./openstatus\-terraform/] +.RE +.PP +Usage: +.IP +.EX +$ openstatus [GLOBAL FLAGS] terraform generate [COMMAND FLAGS] [ARGUMENTS...] +.EE +.PP +The following flags are supported: +.PP +.TS +tab(@); +lw(16.8n) lw(23.7n) cw(15.6n) cw(13.9n). +T{ +Name +T}@T{ +Description +T}@T{ +Default value +T}@T{ +Environment variables +T} +_ +T{ +\f[CR]\-\-access\-token=\(dq\&...\(dq\f[R] (\f[CR]\-t\f[R]) +T}@T{ +OpenStatus API Access Token +T}@T{ +T}@T{ +\f[CR]OPENSTATUS_API_TOKEN\f[R] +T} +T{ +\f[CR]\-\-output\-dir=\(dq\&...\(dq\f[R] (\f[CR]\-o\f[R]) +T}@T{ +Directory to write Terraform files into +T}@T{ +\f[CR]./openstatus\-terraform/\f[R] +T}@T{ +\f[I]none\f[R] +T} +.TE diff --git a/go.mod b/go.mod index c6d0986..b82524f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/openstatusHQ/cli -go 1.24.0 +go 1.25 require github.com/urfave/cli/v3 v3.0.0-alpha9.2 // direct @@ -12,6 +12,7 @@ require ( github.com/charmbracelet/huh v1.0.0 github.com/fatih/color v1.18.0 github.com/google/go-cmp v0.7.0 + github.com/hashicorp/hcl/v2 v2.24.0 github.com/joho/godotenv v1.5.1 github.com/knadh/koanf/parsers/yaml v0.1.0 github.com/knadh/koanf/providers/file v1.1.2 @@ -21,13 +22,17 @@ require ( github.com/olekukonko/tablewriter v1.0.7 github.com/rodaine/table v1.3.0 github.com/urfave/cli-docs/v3 v3.0.0-alpha6 - golang.org/x/term v0.1.0 + github.com/zclconf/go-cty v1.18.0 + golang.org/x/term v0.32.0 + golang.org/x/text v0.25.0 sigs.k8s.io/yaml v1.4.0 ) require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 // indirect buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.11-20230414000709-087bc8072ce4.1 // indirect + github.com/agext/levenshtein v1.2.1 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect @@ -50,6 +55,7 @@ require ( github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect @@ -60,9 +66,10 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/mod v0.17.0 // indirect golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e4ac7cd..0c75bd8 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,10 @@ connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= +github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -63,12 +67,16 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= +github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= @@ -94,6 +102,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -134,8 +144,14 @@ github.com/urfave/cli/v3 v3.0.0-alpha9.2 h1:CL8llQj3dGRLVQQzHxS+ZYRLanOuhyK1fXgL github.com/urfave/cli/v3 v3.0.0-alpha9.2/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/zclconf/go-cty v1.18.0 h1:pJ8+HNI4gFoyRNqVE37wWbJWVw43BZczFo7KUoRczaA= +github.com/zclconf/go-cty v1.18.0/go.mod h1:qpnV6EDNgC1sns/AleL1fvatHw72j+S+nS+MJ+T2CSg= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -143,10 +159,12 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw= -golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/cmd/app.go b/internal/cmd/app.go index 39ff2aa..5bf305c 100644 --- a/internal/cmd/app.go +++ b/internal/cmd/app.go @@ -13,6 +13,7 @@ import ( "github.com/openstatusHQ/cli/internal/run" "github.com/openstatusHQ/cli/internal/statuspage" "github.com/openstatusHQ/cli/internal/statusreport" + "github.com/openstatusHQ/cli/internal/terraform" "github.com/openstatusHQ/cli/internal/whoami" "github.com/urfave/cli/v3" ) @@ -74,6 +75,7 @@ https://docs.openstatus.dev | https://github.com/openstatusHQ/cli/issues/new`, whoami.WhoamiCmd(), login.LoginCmd(), login.LogoutCmd(), + terraform.TerraformCmd(), }, } return app diff --git a/internal/cmd/app_test.go b/internal/cmd/app_test.go index ceb00c6..4ecbe78 100644 --- a/internal/cmd/app_test.go +++ b/internal/cmd/app_test.go @@ -32,8 +32,8 @@ func Test_NewApp(t *testing.T) { t.Run("Has expected commands", func(t *testing.T) { app := cmd.NewApp() - if len(app.Commands) != 8 { - t.Errorf("Expected 8 commands, got %d", len(app.Commands)) + if len(app.Commands) != 9 { + t.Errorf("Expected 9 commands, got %d", len(app.Commands)) } expectedCommands := map[string]bool{ @@ -45,6 +45,7 @@ func Test_NewApp(t *testing.T) { "whoami": false, "login": false, "logout": false, + "terraform": false, } for _, subcmd := range app.Commands { diff --git a/internal/terraform/enums.go b/internal/terraform/enums.go new file mode 100644 index 0000000..6bfd3e6 --- /dev/null +++ b/internal/terraform/enums.go @@ -0,0 +1,178 @@ +package terraform + +import ( + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" + notificationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/notification/v1" + status_pagev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_page/v1" +) + +func periodicityToString(p monitorv1.Periodicity) string { + switch p { + case monitorv1.Periodicity_PERIODICITY_30S: + return "30s" + case monitorv1.Periodicity_PERIODICITY_1M: + return "1m" + case monitorv1.Periodicity_PERIODICITY_5M: + return "5m" + case monitorv1.Periodicity_PERIODICITY_10M: + return "10m" + case monitorv1.Periodicity_PERIODICITY_30M: + return "30m" + case monitorv1.Periodicity_PERIODICITY_1H: + return "1h" + default: + return "10m" + } +} + +func httpMethodToString(m monitorv1.HTTPMethod) string { + switch m { + case monitorv1.HTTPMethod_HTTP_METHOD_GET: + return "GET" + case monitorv1.HTTPMethod_HTTP_METHOD_POST: + return "POST" + case monitorv1.HTTPMethod_HTTP_METHOD_PUT: + return "PUT" + case monitorv1.HTTPMethod_HTTP_METHOD_PATCH: + return "PATCH" + case monitorv1.HTTPMethod_HTTP_METHOD_DELETE: + return "DELETE" + case monitorv1.HTTPMethod_HTTP_METHOD_HEAD: + return "HEAD" + case monitorv1.HTTPMethod_HTTP_METHOD_OPTIONS: + return "OPTIONS" + case monitorv1.HTTPMethod_HTTP_METHOD_TRACE: + return "TRACE" + case monitorv1.HTTPMethod_HTTP_METHOD_CONNECT: + return "CONNECT" + default: + return "GET" + } +} + +func numberComparatorToString(c monitorv1.NumberComparator) string { + switch c { + case monitorv1.NumberComparator_NUMBER_COMPARATOR_EQUAL: + return "eq" + case monitorv1.NumberComparator_NUMBER_COMPARATOR_NOT_EQUAL: + return "neq" + case monitorv1.NumberComparator_NUMBER_COMPARATOR_GREATER_THAN: + return "gt" + case monitorv1.NumberComparator_NUMBER_COMPARATOR_GREATER_THAN_OR_EQUAL: + return "gte" + case monitorv1.NumberComparator_NUMBER_COMPARATOR_LESS_THAN: + return "lt" + case monitorv1.NumberComparator_NUMBER_COMPARATOR_LESS_THAN_OR_EQUAL: + return "lte" + default: + return "eq" + } +} + +func stringComparatorToString(c monitorv1.StringComparator) string { + switch c { + case monitorv1.StringComparator_STRING_COMPARATOR_EQUAL: + return "eq" + case monitorv1.StringComparator_STRING_COMPARATOR_NOT_EQUAL: + return "neq" + case monitorv1.StringComparator_STRING_COMPARATOR_CONTAINS: + return "contains" + case monitorv1.StringComparator_STRING_COMPARATOR_NOT_CONTAINS: + return "not_contains" + case monitorv1.StringComparator_STRING_COMPARATOR_EMPTY: + return "empty" + case monitorv1.StringComparator_STRING_COMPARATOR_NOT_EMPTY: + return "not_empty" + case monitorv1.StringComparator_STRING_COMPARATOR_GREATER_THAN: + return "gt" + case monitorv1.StringComparator_STRING_COMPARATOR_GREATER_THAN_OR_EQUAL: + return "gte" + case monitorv1.StringComparator_STRING_COMPARATOR_LESS_THAN: + return "lt" + case monitorv1.StringComparator_STRING_COMPARATOR_LESS_THAN_OR_EQUAL: + return "lte" + default: + return "eq" + } +} + +func recordComparatorToString(c monitorv1.RecordComparator) string { + switch c { + case monitorv1.RecordComparator_RECORD_COMPARATOR_EQUAL: + return "eq" + case monitorv1.RecordComparator_RECORD_COMPARATOR_NOT_EQUAL: + return "neq" + case monitorv1.RecordComparator_RECORD_COMPARATOR_CONTAINS: + return "contains" + case monitorv1.RecordComparator_RECORD_COMPARATOR_NOT_CONTAINS: + return "not_contains" + default: + return "eq" + } +} + +func notificationProviderToString(p notificationv1.NotificationProvider) string { + switch p { + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_DISCORD: + return "discord" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_EMAIL: + return "email" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_GOOGLE_CHAT: + return "google_chat" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_GRAFANA_ONCALL: + return "grafana_oncall" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_NTFY: + return "ntfy" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_PAGERDUTY: + return "pagerduty" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_OPSGENIE: + return "opsgenie" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_SLACK: + return "slack" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_SMS: + return "sms" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_TELEGRAM: + return "telegram" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_WEBHOOK: + return "webhook" + case notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_WHATSAPP: + return "whatsapp" + default: + return "unknown" + } +} + +func opsgenieRegionToString(r notificationv1.OpsgenieRegion) string { + switch r { + case notificationv1.OpsgenieRegion_OPSGENIE_REGION_US: + return "us" + case notificationv1.OpsgenieRegion_OPSGENIE_REGION_EU: + return "eu" + default: + return "us" + } +} + +func pageComponentTypeToString(t status_pagev1.PageComponentType) string { + switch t { + case status_pagev1.PageComponentType_PAGE_COMPONENT_TYPE_MONITOR: + return "monitor" + case status_pagev1.PageComponentType_PAGE_COMPONENT_TYPE_STATIC: + return "static" + default: + return "static" + } +} + +func pageAccessTypeToString(t status_pagev1.PageAccessType) string { + switch t { + case status_pagev1.PageAccessType_PAGE_ACCESS_TYPE_PUBLIC: + return "public" + case status_pagev1.PageAccessType_PAGE_ACCESS_TYPE_PASSWORD_PROTECTED: + return "password" + case status_pagev1.PageAccessType_PAGE_ACCESS_TYPE_AUTHENTICATED: + return "email-domain" + default: + return "public" + } +} diff --git a/internal/terraform/fetch.go b/internal/terraform/fetch.go new file mode 100644 index 0000000..e7f5c11 --- /dev/null +++ b/internal/terraform/fetch.go @@ -0,0 +1,84 @@ +package terraform + +import ( + "context" + "fmt" + + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" + notificationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/notification/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/notification/v1/notificationv1connect" + status_pagev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_page/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_page/v1/status_pagev1connect" + "connectrpc.com/connect" + "github.com/openstatusHQ/cli/internal/api" +) + +type StatusPageData struct { + Page *status_pagev1.StatusPage + Components []*status_pagev1.PageComponent + Groups []*status_pagev1.PageComponentGroup +} + +type WorkspaceData struct { + HTTPMonitors []*monitorv1.HTTPMonitor + TCPMonitors []*monitorv1.TCPMonitor + DNSMonitors []*monitorv1.DNSMonitor + Notifications []*notificationv1.Notification + StatusPages []StatusPageData +} + +func FetchWorkspaceData(ctx context.Context, apiKey string) (*WorkspaceData, error) { + interceptor := connect.WithInterceptors(api.NewAuthInterceptor(apiKey)) + protoJSON := connect.WithProtoJSON() + + data := &WorkspaceData{} + + // Monitors + monitorClient := monitorv1connect.NewMonitorServiceClient(api.DefaultHTTPClient, api.ConnectBaseURL, interceptor, protoJSON) + monitorResp, err := monitorClient.ListMonitors(ctx, &monitorv1.ListMonitorsRequest{}) + if err != nil { + return nil, fmt.Errorf("failed to list monitors: %w", err) + } + data.HTTPMonitors = monitorResp.GetHttpMonitors() + data.TCPMonitors = monitorResp.GetTcpMonitors() + data.DNSMonitors = monitorResp.GetDnsMonitors() + + // Notifications + notifClient := notificationv1connect.NewNotificationServiceClient(api.DefaultHTTPClient, api.ConnectBaseURL, interceptor, protoJSON) + notifResp, err := notifClient.ListNotifications(ctx, ¬ificationv1.ListNotificationsRequest{}) + if err != nil { + return nil, fmt.Errorf("failed to list notifications: %w", err) + } + for _, summary := range notifResp.GetNotifications() { + req := ¬ificationv1.GetNotificationRequest{} + req.SetId(summary.GetId()) + resp, err := notifClient.GetNotification(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get notification %s: %w", summary.GetId(), err) + } + data.Notifications = append(data.Notifications, resp.GetNotification()) + } + + // Status Pages + pageClient := status_pagev1connect.NewStatusPageServiceClient(api.DefaultHTTPClient, api.ConnectBaseURL, interceptor, protoJSON) + pageResp, err := pageClient.ListStatusPages(ctx, &status_pagev1.ListStatusPagesRequest{}) + if err != nil { + return nil, fmt.Errorf("failed to list status pages: %w", err) + } + for _, summary := range pageResp.GetStatusPages() { + req := &status_pagev1.GetStatusPageContentRequest{} + req.SetId(summary.GetId()) + resp, err := pageClient.GetStatusPageContent(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get status page %s: %w", summary.GetId(), err) + } + data.StatusPages = append(data.StatusPages, StatusPageData{ + Page: resp.GetStatusPage(), + Components: resp.GetComponents(), + Groups: resp.GetGroups(), + }) + } + + return data, nil +} diff --git a/internal/terraform/generate.go b/internal/terraform/generate.go new file mode 100644 index 0000000..9a6a915 --- /dev/null +++ b/internal/terraform/generate.go @@ -0,0 +1,134 @@ +package terraform + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" + "github.com/urfave/cli/v3" +) + +func GetTerraformGenerateCmd() *cli.Command { + return &cli.Command{ + Name: "generate", + Aliases: []string{"gen"}, + Usage: "Generate Terraform configuration from workspace resources", + UsageText: "openstatus terraform generate [--output-dir ./openstatus-terraform/]", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + }, + &cli.StringFlag{ + Name: "output-dir", + Usage: "Directory to write Terraform files into", + Value: "./openstatus-terraform/", + Aliases: []string{"o"}, + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + outputDir := cmd.String("output-dir") + if err := os.MkdirAll(outputDir, 0755); err != nil { + return cli.Exit(fmt.Sprintf("failed to create output directory: %v", err), 1) + } + + s := output.StartSpinner("Fetching workspace resources...") + data, err := FetchWorkspaceData(ctx, apiKey) + output.StopSpinner(s) + if err != nil { + return cli.Exit(err.Error(), 1) + } + + gen := NewGenerator(data) + + // Always write provider.tf + if err := writeFile(filepath.Join(outputDir, "provider.tf"), GenerateProviderFile()); err != nil { + return cli.Exit(fmt.Sprintf("failed to write provider.tf: %v", err), 1) + } + + if gen.TotalResourceCount() == 0 { + fmt.Printf("No resources found in workspace. Only provider.tf was generated in %s\n", outputDir) + return nil + } + + if gen.HasMonitors() { + if err := writeFile(filepath.Join(outputDir, "monitors.tf"), gen.GenerateMonitorsFile().Bytes()); err != nil { + return cli.Exit(fmt.Sprintf("failed to write monitors.tf: %v", err), 1) + } + } + + if gen.HasNotifications() { + if err := writeFile(filepath.Join(outputDir, "notifications.tf"), gen.GenerateNotificationsFile().Bytes()); err != nil { + return cli.Exit(fmt.Sprintf("failed to write notifications.tf: %v", err), 1) + } + } + + if gen.HasStatusPages() { + if err := writeFile(filepath.Join(outputDir, "status_pages.tf"), gen.GenerateStatusPagesFile().Bytes()); err != nil { + return cli.Exit(fmt.Sprintf("failed to write status_pages.tf: %v", err), 1) + } + } + + if err := writeFile(filepath.Join(outputDir, "imports.tf"), gen.GenerateImportsFile().Bytes()); err != nil { + return cli.Exit(fmt.Sprintf("failed to write imports.tf: %v", err), 1) + } + + printSummary(outputDir, data) + return nil + }, + } +} + +func writeFile(path string, content []byte) error { + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer f.Close() + _, err = f.Write(content) + return err +} + +func printSummary(outputDir string, data *WorkspaceData) { + httpCount := len(data.HTTPMonitors) + tcpCount := len(data.TCPMonitors) + dnsCount := len(data.DNSMonitors) + monitorTotal := httpCount + tcpCount + dnsCount + notifCount := len(data.Notifications) + + pageCount := len(data.StatusPages) + compCount := 0 + groupCount := 0 + for _, sp := range data.StatusPages { + compCount += len(sp.Components) + groupCount += len(sp.Groups) + } + + importCount := monitorTotal + notifCount + pageCount + compCount + groupCount + + fmt.Printf("\nGenerated Terraform configuration in %s\n\n", outputDir) + if monitorTotal > 0 { + fmt.Printf(" %d monitors (%d HTTP, %d TCP, %d DNS)\n", monitorTotal, httpCount, tcpCount, dnsCount) + } + if notifCount > 0 { + fmt.Printf(" %d notifications\n", notifCount) + } + if pageCount > 0 { + fmt.Printf(" %d status pages (%d components, %d groups)\n", pageCount, compCount, groupCount) + } + fmt.Printf(" %d import blocks\n", importCount) + fmt.Printf("\nNext steps:\n") + fmt.Printf(" cd %s\n", outputDir) + fmt.Printf(" terraform init\n") + fmt.Printf(" terraform plan\n") +} diff --git a/internal/terraform/generate_test.go b/internal/terraform/generate_test.go new file mode 100644 index 0000000..be3d1e5 --- /dev/null +++ b/internal/terraform/generate_test.go @@ -0,0 +1,228 @@ +package terraform + +import ( + "strings" + "testing" + + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" + notificationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/notification/v1" + status_pagev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_page/v1" +) + +func TestGenerateProviderFile(t *testing.T) { + content := string(GenerateProviderFile()) + mustContain(t, content, `source = "openstatusHQ/openstatus"`) + mustContain(t, content, `version = "~> 0.1.0"`) + mustContain(t, content, `provider "openstatus" {}`) + mustContain(t, content, `OPENSTATUS_API_TOKEN`) +} + +func TestGenerateMonitorsFile_HTTP(t *testing.T) { + m := &monitorv1.HTTPMonitor{} + m.SetId("123") + m.SetName("API Health") + m.SetUrl("https://api.example.com/health") + m.SetPeriodicity(monitorv1.Periodicity_PERIODICITY_5M) + m.SetMethod(monitorv1.HTTPMethod_HTTP_METHOD_POST) + m.SetTimeout(45000) + m.SetRetry(3) + m.SetFollowRedirects(true) + m.SetActive(true) + m.SetRegions([]monitorv1.Region{monitorv1.Region_REGION_FLY_IAD}) + + data := &WorkspaceData{HTTPMonitors: []*monitorv1.HTTPMonitor{m}} + gen := NewGenerator(data) + content := string(gen.GenerateMonitorsFile().Bytes()) + + mustContain(t, content, `resource "openstatus_http_monitor" "api_health"`) + mustContain(t, content, `name = "API Health"`) + mustContain(t, content, `periodicity = "5m"`) + mustContain(t, content, `method = "POST"`) + mustContain(t, content, `active = true`) + mustContain(t, content, `"fly-iad"`) + // timeout, retry, follow_redirects should be omitted at defaults + mustNotContain(t, content, "timeout") + mustNotContain(t, content, "retry") + mustNotContain(t, content, "follow_redirects") +} + +func TestGenerateMonitorsFile_DNS(t *testing.T) { + m := &monitorv1.DNSMonitor{} + m.SetId("456") + m.SetName("DNS Check") + m.SetUri("example.com") + m.SetPeriodicity(monitorv1.Periodicity_PERIODICITY_10M) + m.SetActive(true) + + a := &monitorv1.RecordAssertion{} + a.SetRecord("A") + a.SetTarget("93.184.216.34") + a.SetComparator(monitorv1.RecordComparator_RECORD_COMPARATOR_EQUAL) + m.SetRecordAssertions([]*monitorv1.RecordAssertion{a}) + + data := &WorkspaceData{DNSMonitors: []*monitorv1.DNSMonitor{m}} + gen := NewGenerator(data) + content := string(gen.GenerateMonitorsFile().Bytes()) + + mustContain(t, content, `resource "openstatus_dns_monitor" "dns_check"`) + mustContain(t, content, `record_assertions`) + mustContain(t, content, `record = "A"`) + mustContain(t, content, `target = "93.184.216.34"`) +} + +func TestGenerateNotificationsFile_Slack(t *testing.T) { + n := ¬ificationv1.Notification{} + n.SetId("789") + n.SetName("Slack Alerts") + n.SetProvider(notificationv1.NotificationProvider_NOTIFICATION_PROVIDER_SLACK) + n.SetMonitorIds([]string{"123"}) + + nd := ¬ificationv1.NotificationData{} + sd := ¬ificationv1.SlackData{} + sd.SetWebhookUrl("https://hooks.slack.com/xxx") + nd.SetSlack(sd) + n.SetData(nd) + + data := &WorkspaceData{Notifications: []*notificationv1.Notification{n}} + gen := NewGenerator(data) + content := string(gen.GenerateNotificationsFile().Bytes()) + + mustContain(t, content, `resource "openstatus_notification" "slack_alerts"`) + mustContain(t, content, `provider_type = "slack"`) + mustContain(t, content, `"123"`) + mustContain(t, content, `webhook_url`) +} + +func TestGenerateStatusPagesFile(t *testing.T) { + page := &status_pagev1.StatusPage{} + page.SetId("p1") + page.SetTitle("My Status Page") + page.SetSlug("my-status") + + comp := &status_pagev1.PageComponent{} + comp.SetId("c1") + comp.SetPageId("p1") + comp.SetName("API") + comp.SetType(status_pagev1.PageComponentType_PAGE_COMPONENT_TYPE_STATIC) + comp.SetOrder(1) + + data := &WorkspaceData{ + StatusPages: []StatusPageData{ + {Page: page, Components: []*status_pagev1.PageComponent{comp}}, + }, + } + gen := NewGenerator(data) + content := string(gen.GenerateStatusPagesFile().Bytes()) + + mustContain(t, content, `resource "openstatus_status_page" "my_status_page"`) + mustContain(t, content, `title = "My Status Page"`) + mustContain(t, content, `slug = "my-status"`) + mustContain(t, content, `resource "openstatus_status_page_component" "api"`) + mustContain(t, content, `openstatus_status_page.my_status_page.id`) +} + +func TestGenerateImportsFile(t *testing.T) { + m := &monitorv1.HTTPMonitor{} + m.SetId("123") + m.SetName("API") + + page := &status_pagev1.StatusPage{} + page.SetId("p1") + page.SetTitle("Page") + + comp := &status_pagev1.PageComponent{} + comp.SetId("c1") + comp.SetPageId("p1") + comp.SetName("Comp") + comp.SetType(status_pagev1.PageComponentType_PAGE_COMPONENT_TYPE_STATIC) + + data := &WorkspaceData{ + HTTPMonitors: []*monitorv1.HTTPMonitor{m}, + StatusPages: []StatusPageData{{Page: page, Components: []*status_pagev1.PageComponent{comp}}}, + } + gen := NewGenerator(data) + content := string(gen.GenerateImportsFile().Bytes()) + + mustContain(t, content, `openstatus_http_monitor.api`) + mustContain(t, content, `id = "123"`) + mustContain(t, content, `id = "p1/c1"`) +} + +func TestEmptyWorkspace(t *testing.T) { + data := &WorkspaceData{} + gen := NewGenerator(data) + + if gen.TotalResourceCount() != 0 { + t.Errorf("TotalResourceCount() = %d, want 0", gen.TotalResourceCount()) + } + if gen.HasMonitors() { + t.Error("HasMonitors() should be false") + } + if gen.HasNotifications() { + t.Error("HasNotifications() should be false") + } + if gen.HasStatusPages() { + t.Error("HasStatusPages() should be false") + } +} + +func TestCrossReferenceMonitorInComponent(t *testing.T) { + m := &monitorv1.HTTPMonitor{} + m.SetId("mon-1") + m.SetName("API Monitor") + + page := &status_pagev1.StatusPage{} + page.SetId("p1") + page.SetTitle("Page") + + comp := &status_pagev1.PageComponent{} + comp.SetId("c1") + comp.SetPageId("p1") + comp.SetName("API Comp") + comp.SetType(status_pagev1.PageComponentType_PAGE_COMPONENT_TYPE_MONITOR) + comp.SetMonitorId("mon-1") + + data := &WorkspaceData{ + HTTPMonitors: []*monitorv1.HTTPMonitor{m}, + StatusPages: []StatusPageData{{Page: page, Components: []*status_pagev1.PageComponent{comp}}}, + } + gen := NewGenerator(data) + content := string(gen.GenerateStatusPagesFile().Bytes()) + + mustContain(t, content, `openstatus_http_monitor.api_monitor.id`) +} + +func TestCrossReferenceFallbackToString(t *testing.T) { + page := &status_pagev1.StatusPage{} + page.SetId("p1") + page.SetTitle("Page") + + comp := &status_pagev1.PageComponent{} + comp.SetId("c1") + comp.SetPageId("p1") + comp.SetName("Unknown Monitor Comp") + comp.SetType(status_pagev1.PageComponentType_PAGE_COMPONENT_TYPE_MONITOR) + comp.SetMonitorId("nonexistent-id") + + data := &WorkspaceData{ + StatusPages: []StatusPageData{{Page: page, Components: []*status_pagev1.PageComponent{comp}}}, + } + gen := NewGenerator(data) + content := string(gen.GenerateStatusPagesFile().Bytes()) + + mustContain(t, content, `monitor_id = "nonexistent-id"`) +} + +func mustContain(t *testing.T, content, substr string) { + t.Helper() + if !strings.Contains(content, substr) { + t.Errorf("expected output to contain %q, got:\n%s", substr, content) + } +} + +func mustNotContain(t *testing.T, content, substr string) { + t.Helper() + if strings.Contains(content, substr) { + t.Errorf("expected output NOT to contain %q, got:\n%s", substr, content) + } +} diff --git a/internal/terraform/hcl.go b/internal/terraform/hcl.go new file mode 100644 index 0000000..f6c84a8 --- /dev/null +++ b/internal/terraform/hcl.go @@ -0,0 +1,526 @@ +package terraform + +import ( + "fmt" + + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" + notificationv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/notification/v1" + status_pagev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_page/v1" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/zclconf/go-cty/cty" +) + +type resourceRef struct { + ResourceType string + Name string +} + +type Generator struct { + data *WorkspaceData + registry *NameRegistry + monitorRefs map[string]resourceRef + pageRefs map[string]resourceRef + groupRefs map[string]resourceRef + + httpMonitorNames map[string]string + tcpMonitorNames map[string]string + dnsMonitorNames map[string]string + notifNames map[string]string + pageNames map[string]string + componentNames map[string]string + groupNames map[string]string +} + +func NewGenerator(data *WorkspaceData) *Generator { + g := &Generator{ + data: data, + registry: NewNameRegistry(), + monitorRefs: make(map[string]resourceRef), + pageRefs: make(map[string]resourceRef), + groupRefs: make(map[string]resourceRef), + httpMonitorNames: make(map[string]string), + tcpMonitorNames: make(map[string]string), + dnsMonitorNames: make(map[string]string), + notifNames: make(map[string]string), + pageNames: make(map[string]string), + componentNames: make(map[string]string), + groupNames: make(map[string]string), + } + + for _, m := range data.HTTPMonitors { + name := g.registry.Name("openstatus_http_monitor", m.GetName()) + g.httpMonitorNames[m.GetId()] = name + g.monitorRefs[m.GetId()] = resourceRef{"openstatus_http_monitor", name} + } + for _, m := range data.TCPMonitors { + name := g.registry.Name("openstatus_tcp_monitor", m.GetName()) + g.tcpMonitorNames[m.GetId()] = name + g.monitorRefs[m.GetId()] = resourceRef{"openstatus_tcp_monitor", name} + } + for _, m := range data.DNSMonitors { + name := g.registry.Name("openstatus_dns_monitor", m.GetName()) + g.dnsMonitorNames[m.GetId()] = name + g.monitorRefs[m.GetId()] = resourceRef{"openstatus_dns_monitor", name} + } + for _, n := range data.Notifications { + name := g.registry.Name("openstatus_notification", n.GetName()) + g.notifNames[n.GetId()] = name + } + for _, sp := range data.StatusPages { + page := sp.Page + name := g.registry.Name("openstatus_status_page", page.GetTitle()) + g.pageNames[page.GetId()] = name + g.pageRefs[page.GetId()] = resourceRef{"openstatus_status_page", name} + + for _, grp := range sp.Groups { + gName := g.registry.Name("openstatus_status_page_component_group", grp.GetName()) + g.groupNames[grp.GetId()] = gName + g.groupRefs[grp.GetId()] = resourceRef{"openstatus_status_page_component_group", gName} + } + for _, comp := range sp.Components { + cName := g.registry.Name("openstatus_status_page_component", comp.GetName()) + g.componentNames[comp.GetId()] = cName + } + } + + return g +} + +func GenerateProviderFile() []byte { + return []byte(`terraform { + required_providers { + openstatus = { + source = "openstatusHQ/openstatus" + version = "~> 0.1.0" + } + } +} + +# Set OPENSTATUS_API_TOKEN environment variable or configure api_token below +provider "openstatus" {} +`) +} + +func (g *Generator) GenerateMonitorsFile() *hclwrite.File { + f := hclwrite.NewEmptyFile() + body := f.Body() + + for _, m := range g.data.HTTPMonitors { + name := g.httpMonitorNames[m.GetId()] + block := body.AppendNewBlock("resource", []string{"openstatus_http_monitor", name}) + b := block.Body() + + b.SetAttributeValue("name", cty.StringVal(m.GetName())) + b.SetAttributeValue("url", cty.StringVal(m.GetUrl())) + b.SetAttributeValue("periodicity", cty.StringVal(periodicityToString(m.GetPeriodicity()))) + + if method := httpMethodToString(m.GetMethod()); method != "GET" { + b.SetAttributeValue("method", cty.StringVal(method)) + } + if m.GetBody() != "" { + b.SetAttributeValue("body", cty.StringVal(m.GetBody())) + } + if m.GetTimeout() != 45000 { + b.SetAttributeValue("timeout", cty.NumberIntVal(m.GetTimeout())) + } + if m.GetDegradedAt() != 0 { + b.SetAttributeValue("degraded_at", cty.NumberIntVal(m.GetDegradedAt())) + } + if m.GetRetry() != 3 { + b.SetAttributeValue("retry", cty.NumberIntVal(m.GetRetry())) + } + if !m.GetFollowRedirects() { + b.SetAttributeValue("follow_redirects", cty.BoolVal(false)) + } + b.SetAttributeValue("active", cty.BoolVal(m.GetActive())) + b.SetAttributeValue("public", cty.BoolVal(m.GetPublic())) + if m.GetDescription() != "" { + b.SetAttributeValue("description", cty.StringVal(m.GetDescription())) + } + + writeRegions(b, m.GetRegions()) + writeHeaders(b, m.GetHeaders()) + writeStatusCodeAssertions(b, m.GetStatusCodeAssertions()) + writeBodyAssertions(b, m.GetBodyAssertions()) + writeHeaderAssertions(b, m.GetHeaderAssertions()) + + body.AppendNewline() + } + + for _, m := range g.data.TCPMonitors { + name := g.tcpMonitorNames[m.GetId()] + block := body.AppendNewBlock("resource", []string{"openstatus_tcp_monitor", name}) + b := block.Body() + + b.SetAttributeValue("name", cty.StringVal(m.GetName())) + b.SetAttributeValue("uri", cty.StringVal(m.GetUri())) + b.SetAttributeValue("periodicity", cty.StringVal(periodicityToString(m.GetPeriodicity()))) + + if m.GetTimeout() != 45000 { + b.SetAttributeValue("timeout", cty.NumberIntVal(m.GetTimeout())) + } + if m.GetDegradedAt() != 0 { + b.SetAttributeValue("degraded_at", cty.NumberIntVal(m.GetDegradedAt())) + } + if m.GetRetry() != 3 { + b.SetAttributeValue("retry", cty.NumberIntVal(m.GetRetry())) + } + b.SetAttributeValue("active", cty.BoolVal(m.GetActive())) + b.SetAttributeValue("public", cty.BoolVal(m.GetPublic())) + if m.GetDescription() != "" { + b.SetAttributeValue("description", cty.StringVal(m.GetDescription())) + } + + writeRegions(b, m.GetRegions()) + + body.AppendNewline() + } + + for _, m := range g.data.DNSMonitors { + name := g.dnsMonitorNames[m.GetId()] + block := body.AppendNewBlock("resource", []string{"openstatus_dns_monitor", name}) + b := block.Body() + + b.SetAttributeValue("name", cty.StringVal(m.GetName())) + b.SetAttributeValue("uri", cty.StringVal(m.GetUri())) + b.SetAttributeValue("periodicity", cty.StringVal(periodicityToString(m.GetPeriodicity()))) + + if m.GetTimeout() != 45000 { + b.SetAttributeValue("timeout", cty.NumberIntVal(m.GetTimeout())) + } + if m.GetDegradedAt() != 0 { + b.SetAttributeValue("degraded_at", cty.NumberIntVal(m.GetDegradedAt())) + } + if m.GetRetry() != 3 { + b.SetAttributeValue("retry", cty.NumberIntVal(m.GetRetry())) + } + b.SetAttributeValue("active", cty.BoolVal(m.GetActive())) + b.SetAttributeValue("public", cty.BoolVal(m.GetPublic())) + if m.GetDescription() != "" { + b.SetAttributeValue("description", cty.StringVal(m.GetDescription())) + } + + writeRegions(b, m.GetRegions()) + + for _, a := range m.GetRecordAssertions() { + ab := b.AppendNewBlock("record_assertions", nil).Body() + ab.SetAttributeValue("record", cty.StringVal(a.GetRecord())) + ab.SetAttributeValue("target", cty.StringVal(a.GetTarget())) + ab.SetAttributeValue("comparator", cty.StringVal(recordComparatorToString(a.GetComparator()))) + } + + body.AppendNewline() + } + + return f +} + +func (g *Generator) GenerateNotificationsFile() *hclwrite.File { + f := hclwrite.NewEmptyFile() + body := f.Body() + + for _, n := range g.data.Notifications { + name := g.notifNames[n.GetId()] + block := body.AppendNewBlock("resource", []string{"openstatus_notification", name}) + b := block.Body() + + b.SetAttributeValue("name", cty.StringVal(n.GetName())) + b.SetAttributeValue("provider_type", cty.StringVal(notificationProviderToString(n.GetProvider()))) + + if ids := n.GetMonitorIds(); len(ids) > 0 { + vals := make([]cty.Value, len(ids)) + for i, id := range ids { + vals[i] = cty.StringVal(id) + } + b.SetAttributeValue("monitor_ids", cty.SetVal(vals)) + } + + g.writeNotificationProvider(b, n) + + body.AppendNewline() + } + + return f +} + +func (g *Generator) writeNotificationProvider(b *hclwrite.Body, n *notificationv1.Notification) { + data := n.GetData() + if data == nil { + return + } + + switch d := data.Data.(type) { + case *notificationv1.NotificationData_Discord: + pb := b.AppendNewBlock("discord", nil).Body() + pb.SetAttributeValue("webhook_url", cty.StringVal(d.Discord.GetWebhookUrl())) + case *notificationv1.NotificationData_Email: + pb := b.AppendNewBlock("email", nil).Body() + pb.SetAttributeValue("email", cty.StringVal(d.Email.GetEmail())) + case *notificationv1.NotificationData_Slack: + pb := b.AppendNewBlock("slack", nil).Body() + pb.SetAttributeValue("webhook_url", cty.StringVal(d.Slack.GetWebhookUrl())) + case *notificationv1.NotificationData_Pagerduty: + pb := b.AppendNewBlock("pagerduty", nil).Body() + appendTODOComment(pb) + pb.SetAttributeValue("integration_key", cty.StringVal("REPLACE_ME")) + case *notificationv1.NotificationData_Opsgenie: + pb := b.AppendNewBlock("opsgenie", nil).Body() + appendTODOComment(pb) + pb.SetAttributeValue("api_key", cty.StringVal("REPLACE_ME")) + pb.SetAttributeValue("region", cty.StringVal(opsgenieRegionToString(d.Opsgenie.GetRegion()))) + case *notificationv1.NotificationData_Webhook: + pb := b.AppendNewBlock("webhook", nil).Body() + pb.SetAttributeValue("endpoint", cty.StringVal(d.Webhook.GetEndpoint())) + for _, h := range d.Webhook.GetHeaders() { + hb := pb.AppendNewBlock("headers", nil).Body() + hb.SetAttributeValue("key", cty.StringVal(h.GetKey())) + hb.SetAttributeValue("value", cty.StringVal(h.GetValue())) + } + case *notificationv1.NotificationData_Telegram: + pb := b.AppendNewBlock("telegram", nil).Body() + pb.SetAttributeValue("chat_id", cty.StringVal(d.Telegram.GetChatId())) + case *notificationv1.NotificationData_Sms: + pb := b.AppendNewBlock("sms", nil).Body() + pb.SetAttributeValue("phone_number", cty.StringVal(d.Sms.GetPhoneNumber())) + case *notificationv1.NotificationData_Whatsapp: + pb := b.AppendNewBlock("whatsapp", nil).Body() + pb.SetAttributeValue("phone_number", cty.StringVal(d.Whatsapp.GetPhoneNumber())) + case *notificationv1.NotificationData_GoogleChat: + pb := b.AppendNewBlock("google_chat", nil).Body() + pb.SetAttributeValue("webhook_url", cty.StringVal(d.GoogleChat.GetWebhookUrl())) + case *notificationv1.NotificationData_GrafanaOncall: + pb := b.AppendNewBlock("grafana_oncall", nil).Body() + pb.SetAttributeValue("webhook_url", cty.StringVal(d.GrafanaOncall.GetWebhookUrl())) + case *notificationv1.NotificationData_Ntfy: + pb := b.AppendNewBlock("ntfy", nil).Body() + pb.SetAttributeValue("topic", cty.StringVal(d.Ntfy.GetTopic())) + if d.Ntfy.GetServerUrl() != "" { + pb.SetAttributeValue("server_url", cty.StringVal(d.Ntfy.GetServerUrl())) + } + if d.Ntfy.HasToken() { + appendTODOComment(pb) + pb.SetAttributeValue("token", cty.StringVal("REPLACE_ME")) + } + } +} + +func (g *Generator) GenerateStatusPagesFile() *hclwrite.File { + f := hclwrite.NewEmptyFile() + body := f.Body() + + for _, sp := range g.data.StatusPages { + page := sp.Page + pageName := g.pageNames[page.GetId()] + block := body.AppendNewBlock("resource", []string{"openstatus_status_page", pageName}) + b := block.Body() + + b.SetAttributeValue("title", cty.StringVal(page.GetTitle())) + b.SetAttributeValue("slug", cty.StringVal(page.GetSlug())) + if page.GetDescription() != "" { + b.SetAttributeValue("description", cty.StringVal(page.GetDescription())) + } + if page.GetHomepageUrl() != "" { + b.SetAttributeValue("homepage_url", cty.StringVal(page.GetHomepageUrl())) + } + if page.GetContactUrl() != "" { + b.SetAttributeValue("contact_url", cty.StringVal(page.GetContactUrl())) + } + if page.GetIcon() != "" { + b.SetAttributeValue("icon", cty.StringVal(page.GetIcon())) + } + if page.GetCustomDomain() != "" { + b.SetAttributeValue("custom_domain", cty.StringVal(page.GetCustomDomain())) + } + + accessType := pageAccessTypeToString(page.GetAccessType()) + if accessType != "public" { + b.SetAttributeValue("access_type", cty.StringVal(accessType)) + if accessType == "password" { + appendTODOComment(b) + b.SetAttributeValue("password", cty.StringVal("REPLACE_ME")) + } + } + + body.AppendNewline() + + // Component groups first + for _, grp := range sp.Groups { + gName := g.groupNames[grp.GetId()] + gb := body.AppendNewBlock("resource", []string{"openstatus_status_page_component_group", gName}).Body() + setTraversalOrString(gb, "page_id", g.pageRefs, page.GetId()) + gb.SetAttributeValue("name", cty.StringVal(grp.GetName())) + body.AppendNewline() + } + + // Components + for _, comp := range sp.Components { + cName := g.componentNames[comp.GetId()] + cb := body.AppendNewBlock("resource", []string{"openstatus_status_page_component", cName}).Body() + setTraversalOrString(cb, "page_id", g.pageRefs, page.GetId()) + cb.SetAttributeValue("type", cty.StringVal(pageComponentTypeToString(comp.GetType()))) + + if comp.GetType() == status_pagev1.PageComponentType_PAGE_COMPONENT_TYPE_MONITOR && comp.GetMonitorId() != "" { + setTraversalOrString(cb, "monitor_id", g.monitorRefs, comp.GetMonitorId()) + } + if comp.GetName() != "" { + cb.SetAttributeValue("name", cty.StringVal(comp.GetName())) + } + if comp.GetDescription() != "" { + cb.SetAttributeValue("description", cty.StringVal(comp.GetDescription())) + } + cb.SetAttributeValue("order", cty.NumberIntVal(int64(comp.GetOrder()))) + + if comp.GetGroupId() != "" { + setTraversalOrString(cb, "group_id", g.groupRefs, comp.GetGroupId()) + cb.SetAttributeValue("group_order", cty.NumberIntVal(int64(comp.GetGroupOrder()))) + } + + body.AppendNewline() + } + } + + return f +} + +func (g *Generator) GenerateImportsFile() *hclwrite.File { + f := hclwrite.NewEmptyFile() + body := f.Body() + + for _, m := range g.data.HTTPMonitors { + writeImportBlock(body, "openstatus_http_monitor", g.httpMonitorNames[m.GetId()], m.GetId()) + } + for _, m := range g.data.TCPMonitors { + writeImportBlock(body, "openstatus_tcp_monitor", g.tcpMonitorNames[m.GetId()], m.GetId()) + } + for _, m := range g.data.DNSMonitors { + writeImportBlock(body, "openstatus_dns_monitor", g.dnsMonitorNames[m.GetId()], m.GetId()) + } + for _, n := range g.data.Notifications { + writeImportBlock(body, "openstatus_notification", g.notifNames[n.GetId()], n.GetId()) + } + for _, sp := range g.data.StatusPages { + page := sp.Page + writeImportBlock(body, "openstatus_status_page", g.pageNames[page.GetId()], page.GetId()) + for _, grp := range sp.Groups { + writeImportBlock(body, "openstatus_status_page_component_group", g.groupNames[grp.GetId()], fmt.Sprintf("%s/%s", page.GetId(), grp.GetId())) + } + for _, comp := range sp.Components { + writeImportBlock(body, "openstatus_status_page_component", g.componentNames[comp.GetId()], fmt.Sprintf("%s/%s", page.GetId(), comp.GetId())) + } + } + + return f +} + +func (g *Generator) TotalResourceCount() int { + count := len(g.data.HTTPMonitors) + len(g.data.TCPMonitors) + len(g.data.DNSMonitors) + len(g.data.Notifications) + for _, sp := range g.data.StatusPages { + count += 1 + len(sp.Components) + len(sp.Groups) + } + return count +} + +func (g *Generator) HasMonitors() bool { + return len(g.data.HTTPMonitors) > 0 || len(g.data.TCPMonitors) > 0 || len(g.data.DNSMonitors) > 0 +} + +func (g *Generator) HasNotifications() bool { + return len(g.data.Notifications) > 0 +} + +func (g *Generator) HasStatusPages() bool { + return len(g.data.StatusPages) > 0 +} + +// helpers + +func writeRegions(b *hclwrite.Body, regions []monitorv1.Region) { + if len(regions) == 0 { + return + } + vals := make([]cty.Value, len(regions)) + for i, r := range regions { + vals[i] = cty.StringVal(regionToTerraform(r)) + } + b.SetAttributeValue("regions", cty.ListVal(vals)) +} + +func writeHeaders(b *hclwrite.Body, headers []*monitorv1.Headers) { + for _, h := range headers { + if h.GetKey() == "" { + continue + } + hb := b.AppendNewBlock("headers", nil).Body() + hb.SetAttributeValue("key", cty.StringVal(h.GetKey())) + hb.SetAttributeValue("value", cty.StringVal(h.GetValue())) + } +} + +func writeStatusCodeAssertions(b *hclwrite.Body, assertions []*monitorv1.StatusCodeAssertion) { + for _, a := range assertions { + ab := b.AppendNewBlock("status_code_assertions", nil).Body() + ab.SetAttributeValue("target", cty.NumberIntVal(int64(a.GetTarget()))) + ab.SetAttributeValue("comparator", cty.StringVal(numberComparatorToString(a.GetComparator()))) + } +} + +func writeBodyAssertions(b *hclwrite.Body, assertions []*monitorv1.BodyAssertion) { + for _, a := range assertions { + ab := b.AppendNewBlock("body_assertions", nil).Body() + ab.SetAttributeValue("target", cty.StringVal(a.GetTarget())) + ab.SetAttributeValue("comparator", cty.StringVal(stringComparatorToString(a.GetComparator()))) + } +} + +func writeHeaderAssertions(b *hclwrite.Body, assertions []*monitorv1.HeaderAssertion) { + for _, a := range assertions { + ab := b.AppendNewBlock("header_assertions", nil).Body() + ab.SetAttributeValue("key", cty.StringVal(a.GetKey())) + ab.SetAttributeValue("target", cty.StringVal(a.GetTarget())) + ab.SetAttributeValue("comparator", cty.StringVal(stringComparatorToString(a.GetComparator()))) + } +} + +func writeImportBlock(body *hclwrite.Body, resourceType, name, id string) { + block := body.AppendNewBlock("import", nil) + b := block.Body() + b.SetAttributeRaw("to", traversalTokens(resourceType, name)) + b.SetAttributeValue("id", cty.StringVal(id)) + body.AppendNewline() +} + +func setTraversalOrString(b *hclwrite.Body, attr string, refs map[string]resourceRef, id string) { + if ref, ok := refs[id]; ok { + b.SetAttributeRaw(attr, traversalTokens(ref.ResourceType, ref.Name, "id")) + } else { + b.SetAttributeValue(attr, cty.StringVal(id)) + } +} + +func traversalTokens(parts ...string) hclwrite.Tokens { + tokens := hclwrite.Tokens{} + for i, part := range parts { + if i > 0 { + tokens = append(tokens, &hclwrite.Token{ + Type: hclsyntax.TokenDot, + Bytes: []byte("."), + }) + } + tokens = append(tokens, &hclwrite.Token{ + Type: hclsyntax.TokenIdent, + Bytes: []byte(part), + }) + } + tokens = append(tokens, &hclwrite.Token{ + Type: hclsyntax.TokenNewline, + Bytes: []byte("\n"), + }) + return tokens +} + +func appendTODOComment(b *hclwrite.Body) { + b.AppendUnstructuredTokens(hclwrite.Tokens{ + {Type: hclsyntax.TokenComment, Bytes: []byte("# TODO: set the actual value — not available from the API\n")}, + }) +} diff --git a/internal/terraform/naming.go b/internal/terraform/naming.go new file mode 100644 index 0000000..864013e --- /dev/null +++ b/internal/terraform/naming.go @@ -0,0 +1,69 @@ +package terraform + +import ( + "fmt" + "regexp" + "strings" + "unicode" + + "golang.org/x/text/unicode/norm" +) + +var nonAlphanumRegexp = regexp.MustCompile(`[^a-z0-9]+`) +var multiUnderscoreRegexp = regexp.MustCompile(`_+`) + +func sanitizeName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "unnamed" + } + + // Normalize unicode to decomposed form, then strip non-ASCII + name = norm.NFD.String(name) + var b strings.Builder + for _, r := range name { + if r < unicode.MaxASCII { + b.WriteRune(r) + } + } + name = b.String() + + name = strings.ToLower(name) + name = nonAlphanumRegexp.ReplaceAllString(name, "_") + name = multiUnderscoreRegexp.ReplaceAllString(name, "_") + name = strings.Trim(name, "_") + + if name == "" { + return "unnamed" + } + + if name[0] >= '0' && name[0] <= '9' { + name = "resource_" + name + } + + return name +} + +type NameRegistry struct { + used map[string]map[string]int +} + +func NewNameRegistry() *NameRegistry { + return &NameRegistry{used: make(map[string]map[string]int)} +} + +func (r *NameRegistry) Name(resourceType, rawName string) string { + name := sanitizeName(rawName) + + if _, ok := r.used[resourceType]; !ok { + r.used[resourceType] = make(map[string]int) + } + + count := r.used[resourceType][name] + r.used[resourceType][name] = count + 1 + + if count == 0 { + return name + } + return fmt.Sprintf("%s_%d", name, count+1) +} diff --git a/internal/terraform/naming_test.go b/internal/terraform/naming_test.go new file mode 100644 index 0000000..b321075 --- /dev/null +++ b/internal/terraform/naming_test.go @@ -0,0 +1,64 @@ +package terraform + +import "testing" + +func TestSanitizeName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"API Health Check", "api_health_check"}, + {"3xx Error Monitor", "resource_3xx_error_monitor"}, + {"hello---world", "hello_world"}, + {"", "unnamed"}, + {" ", "unnamed"}, + {"café / über", "cafe_uber"}, + {"Simple", "simple"}, + {"already_valid", "already_valid"}, + {"___leading_trailing___", "leading_trailing"}, + {"UPPER CASE", "upper_case"}, + {"special!@#$%chars", "special_chars"}, + {"123", "resource_123"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := sanitizeName(tt.input) + if got != tt.want { + t.Errorf("sanitizeName(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestNameRegistryDeduplication(t *testing.T) { + reg := NewNameRegistry() + + got1 := reg.Name("openstatus_http_monitor", "API") + got2 := reg.Name("openstatus_http_monitor", "API") + got3 := reg.Name("openstatus_http_monitor", "API") + + if got1 != "api" { + t.Errorf("first = %q, want %q", got1, "api") + } + if got2 != "api_2" { + t.Errorf("second = %q, want %q", got2, "api_2") + } + if got3 != "api_3" { + t.Errorf("third = %q, want %q", got3, "api_3") + } +} + +func TestNameRegistryPerTypeScopee(t *testing.T) { + reg := NewNameRegistry() + + httpName := reg.Name("openstatus_http_monitor", "API") + tcpName := reg.Name("openstatus_tcp_monitor", "API") + + if httpName != "api" { + t.Errorf("http = %q, want %q", httpName, "api") + } + if tcpName != "api" { + t.Errorf("tcp = %q, want %q", tcpName, "api") + } +} diff --git a/internal/terraform/regions.go b/internal/terraform/regions.go new file mode 100644 index 0000000..b0500fb --- /dev/null +++ b/internal/terraform/regions.go @@ -0,0 +1,71 @@ +package terraform + +import ( + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" +) + +func regionToTerraform(r monitorv1.Region) string { + switch r { + // Fly.io (18) + case monitorv1.Region_REGION_FLY_AMS: + return "fly-ams" + case monitorv1.Region_REGION_FLY_ARN: + return "fly-arn" + case monitorv1.Region_REGION_FLY_BOM: + return "fly-bom" + case monitorv1.Region_REGION_FLY_CDG: + return "fly-cdg" + case monitorv1.Region_REGION_FLY_DFW: + return "fly-dfw" + case monitorv1.Region_REGION_FLY_EWR: + return "fly-ewr" + case monitorv1.Region_REGION_FLY_FRA: + return "fly-fra" + case monitorv1.Region_REGION_FLY_GRU: + return "fly-gru" + case monitorv1.Region_REGION_FLY_IAD: + return "fly-iad" + case monitorv1.Region_REGION_FLY_JNB: + return "fly-jnb" + case monitorv1.Region_REGION_FLY_LAX: + return "fly-lax" + case monitorv1.Region_REGION_FLY_LHR: + return "fly-lhr" + case monitorv1.Region_REGION_FLY_NRT: + return "fly-nrt" + case monitorv1.Region_REGION_FLY_ORD: + return "fly-ord" + case monitorv1.Region_REGION_FLY_SJC: + return "fly-sjc" + case monitorv1.Region_REGION_FLY_SIN: + return "fly-sin" + case monitorv1.Region_REGION_FLY_SYD: + return "fly-syd" + case monitorv1.Region_REGION_FLY_YYZ: + return "fly-yyz" + // Koyeb (6) + case monitorv1.Region_REGION_KOYEB_FRA: + return "koyeb-fra" + case monitorv1.Region_REGION_KOYEB_PAR: + return "koyeb-par" + case monitorv1.Region_REGION_KOYEB_SFO: + return "koyeb-sfo" + case monitorv1.Region_REGION_KOYEB_SIN: + return "koyeb-sin" + case monitorv1.Region_REGION_KOYEB_TYO: + return "koyeb-tyo" + case monitorv1.Region_REGION_KOYEB_WAS: + return "koyeb-was" + // Railway (4) + case monitorv1.Region_REGION_RAILWAY_US_WEST2: + return "railway-us-west2" + case monitorv1.Region_REGION_RAILWAY_US_EAST4: + return "railway-us-east4" + case monitorv1.Region_REGION_RAILWAY_EUROPE_WEST4: + return "railway-europe-west4" + case monitorv1.Region_REGION_RAILWAY_ASIA_SOUTHEAST1: + return "railway-asia-southeast1" + default: + return r.String() + } +} diff --git a/internal/terraform/terraform.go b/internal/terraform/terraform.go new file mode 100644 index 0000000..7268b28 --- /dev/null +++ b/internal/terraform/terraform.go @@ -0,0 +1,16 @@ +package terraform + +import ( + "github.com/urfave/cli/v3" +) + +func TerraformCmd() *cli.Command { + return &cli.Command{ + Name: "terraform", + Aliases: []string{"tf"}, + Usage: "Generate Terraform configuration", + Commands: []*cli.Command{ + GetTerraformGenerateCmd(), + }, + } +} diff --git a/skills/cli/SKILL.md b/skills/cli/SKILL.md index 9a5cafb..2d1fe8c 100644 --- a/skills/cli/SKILL.md +++ b/skills/cli/SKILL.md @@ -1,7 +1,7 @@ --- name: openstatus-cli description: | - OpenStatus CLI for managing uptime monitors, incident reports, status pages, maintenance windows, and synthetic tests. Use this skill whenever the user wants to monitor a website or API, set up uptime checks, create or manage monitors, report an incident, update a status page, schedule maintenance, run synthetic tests, check latency or availability, define monitors as code, or use the openstatus command. Also trigger when the user says "is my site up", "check my endpoint", "create a status report", "monitor this URL", "run uptime tests", "set up monitoring", "our API is down", "schedule maintenance", "maintenance window", "planned downtime", or mentions openstatus in any context. This skill knows the full CLI — commands, flags, config format, and workflows — so Claude can act without guessing. + OpenStatus CLI for managing uptime monitors, incident reports, status pages, maintenance windows, and synthetic tests. Use this skill whenever the user wants to monitor a website or API, set up uptime checks, create or manage monitors, report an incident, update a status page, schedule maintenance, run synthetic tests, check latency or availability, define monitors as code, generate Terraform configuration, export to Terraform, or use the openstatus command. Also trigger when the user says "is my site up", "check my endpoint", "create a status report", "monitor this URL", "run uptime tests", "set up monitoring", "our API is down", "schedule maintenance", "maintenance window", "planned downtime", "terraform", "generate terraform", "export to terraform", "infrastructure as code", or mentions openstatus in any context. This skill knows the full CLI — commands, flags, config format, and workflows — so Claude can act without guessing. allowed-tools: - Bash(openstatus *) --- @@ -51,9 +51,10 @@ Token resolution order: | Update a maintenance window | `maintenance update ` | Change title, message, or time window | | Delete a maintenance window | `maintenance delete ` | Remove a maintenance window | | Run synthetic tests | `run` | Execute on-demand tests for specific monitors | +| Generate Terraform config | `terraform generate` | Export workspace resources to Terraform HCL files | | Check workspace | `whoami` | Verify auth and workspace info | -Command aliases: `monitors` = `m`, `status-report` = `sr`, `status-page` = `sp`, `maintenance` = `mt`, `run` = `r`, `whoami` = `w`. +Command aliases: `monitors` = `m`, `status-report` = `sr`, `status-page` = `sp`, `maintenance` = `mt`, `terraform` = `tf`, `run` = `r`, `whoami` = `w`. ## Workflows @@ -289,6 +290,35 @@ openstatus maintenance info openstatus status-page info ``` +### Terraform export + +Generate Terraform HCL configuration from all workspace resources. This creates ready-to-use `.tf` files with import blocks for adopting Terraform on an existing OpenStatus setup. + +```bash +openstatus terraform generate +``` + +This creates an `openstatus-terraform/` directory with: +- `provider.tf` — Terraform provider configuration +- `monitors.tf` — all HTTP, TCP, and DNS monitors +- `notifications.tf` — all notification channels with provider-specific blocks +- `status_pages.tf` — status pages, components, and component groups +- `imports.tf` — Terraform 1.5+ import blocks for all resources + +**Custom output directory:** +```bash +openstatus terraform generate --output-dir ./infra/openstatus/ +``` + +**After generating:** +```bash +cd openstatus-terraform +terraform init +terraform plan +``` + +Sensitive values (passwords, API keys) are emitted as `"REPLACE_ME"` with a TODO comment — update them before running `terraform apply`. + ## Global Flags Every command supports these: