From c388fff6ccaf5e2a230f8fd34b7958d1998a0673 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 20 Aug 2026 18:36:21 -0500 Subject: [PATCH 01/32] chore(go): add Go module scaffolding Adds the Go module rooted at github.com/Automattic/vip, and the .gitignore entries for Go build artifacts and the vendored go-search-replace binaries. The schema.gql negation is required: the blanket schema.gql rule would otherwise exclude internal/gql/schema.gql, which is checked in. go.mod and go.sum are intentionally not `go mod tidy`-clean: tidy strips the go.sum entries for genqlient's own dependencies, which breaks `make verify-gql-stale`. Ported from vip-cli-golang@72ef2f89 (feature/go-rewrite). --- .gitignore | 16 +++++++ go.mod | 36 +++++++++++++++ go.sum | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 go.mod create mode 100644 go.sum diff --git a/.gitignore b/.gitignore index 5f81d89ae..3d4aad8a2 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,19 @@ coverage *.iml schema.gql + +# Keep the checked-in GraphQL schema despite the blanket schema.gql rule above +!internal/gql/schema.gql + +# Go build artifacts +/bin/ +*.exe +coverage.out +go.work +go.work.sum + +# Vendored go-search-replace binaries: fetched + checksum-verified by +# `make vendor-search-replace` from the pinned release in +# third_party/go-search-replace/MANIFEST (which IS tracked). Binaries stay out +# of git so the repo does not carry ~19 MB of executables per upgrade. +third_party/go-search-replace/*/ diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..f10b4fa99 --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module github.com/Automattic/vip + +go 1.27 + +require ( + github.com/AlecAivazis/survey/v2 v2.3.7 + github.com/Khan/genqlient v0.8.1 + github.com/coder/websocket v1.8.15 + github.com/creack/pty v1.1.17 + github.com/fatih/color v1.19.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c + github.com/spf13/cobra v1.10.2 + github.com/vektah/gqlparser/v2 v2.5.19 + github.com/zalando/go-keyring v0.2.8 + golang.org/x/crypto v0.53.0 + golang.org/x/net v0.55.0 + golang.org/x/sys v0.46.0 + golang.org/x/term v0.44.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 + github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/text v0.38.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..fa133eccf --- /dev/null +++ b/go.sum @@ -0,0 +1,127 @@ +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs= +github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= +github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +github.com/alexflint/go-arg v1.5.1 h1:nBuWUCpuRy0snAG+uIJ6N0UvYxpxA0/ghA/AaHxlT8Y= +github.com/alexflint/go-arg v1.5.1/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8= +github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= +github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedib0t/go-pretty/v6 v6.8.0 h1:fQOTjATVQl5RhssBro6ZuHANFybCkmJ7FjYPo4b7sEY= +github.com/jedib0t/go-pretty/v6 v6.8.0/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vektah/gqlparser/v2 v2.5.19 h1:bhCPCX1D4WWzCDvkPl4+TP1N8/kLrWnp43egplt7iSg= +github.com/vektah/gqlparser/v2 v2.5.19/go.mod h1:y7kvl5bBlDeuWIvLtA9849ncyvx6/lj06RsMrEjVy3U= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From cd591c0a222e88375b970869ed0a06c0a7e258cf Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 20 Aug 2026 18:36:21 -0500 Subject: [PATCH 02/32] build(go): add Makefile and Windows build script third_party/go-search-replace/MANIFEST pins the go-search-replace release; the per-platform binaries are fetched and checksum-verified by `make vendor-search-replace` and stay out of git. Ported from vip-cli-golang@72ef2f89 (feature/go-rewrite). --- Makefile | 315 +++++++++++++++++++++++++ make.ps1 | 155 ++++++++++++ third_party/go-search-replace/MANIFEST | 34 +++ 3 files changed, 504 insertions(+) create mode 100644 Makefile create mode 100644 make.ps1 create mode 100644 third_party/go-search-replace/MANIFEST diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..78ac8606f --- /dev/null +++ b/Makefile @@ -0,0 +1,315 @@ +# vip-next Makefile + +GO ?= go +GOFLAGS ?= +LDFLAGS := -s -w \ + -X github.com/Automattic/vip/internal/version.Version=$(shell git describe --tags --always --dirty 2>/dev/null || echo dev) \ + -X github.com/Automattic/vip/internal/version.Commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) + +BIN_DIR := bin +BIN_NAME := vip-next + +# The Node CLI entrypoint the differential parity scenario diffs vip-next +# against. It is a BUILT artifact (`npm ci && npm run build`); when it is +# absent the scenario skips with a banner naming what is missing rather than +# failing a developer who has no node_modules. +NODE_VIP_BIN ?= $(CURDIR)/dist/bin/vip.js + +.PHONY: build search-replace-bin test test-parity test-parity-unit test-parity-unit-hostile lint tidy tidy-gql verify-gql-stale clean node-vip-bin-status require-node-vip-bin + +build: + mkdir -p $(BIN_DIR) + CGO_ENABLED=0 $(GO) build -buildvcs=false -trimpath -ldflags="$(LDFLAGS)" -o $(BIN_DIR)/$(BIN_NAME) ./cmd/vip-next + @$(MAKE) --no-print-directory search-replace-bin + +# Bundle the host's go-search-replace binary next to vip-next so `import sql` +# (--search-replace) and `dev-env sync sql` resolve it without a runtime +# download. Uses the real per-platform binaries vendored under __fixtures__; +# proper release-tarball bundling of official binaries is the M8 task. +# +# This FAILS the build on a platform we have no binary for, rather than warning +# and exiting 0. Exiting 0 produced a vip-next that built fine and then died +# only when the user reached `search-replace`, `import sql --search-replace` or +# `dev-env sync sql` — i.e. the discovery moment was moved from `make build` to +# the middle of someone's import. linux/arm64 is the live gap (Graviton, ARM CI, +# Docker on Apple Silicon), and windows/arm64. +# +# Two deliberate escape hatches, because neither case is a broken setup: +# VIP_SEARCH_REPLACE_BIN= the user supplied their own binary; that is +# the first entry in searchreplace.ResolveBinary +# and it makes the bundle irrelevant. +# ALLOW_MISSING_SEARCH_REPLACE=1 the user knowingly wants a build without +# search-replace support. +# +# NOTE: __fixtures__/ is a vendored mirror of Automattic/vip and must stay +# byte-identical to it, so a new architecture CANNOT simply be dropped in there +# — the next sync would revert it. See docs/BUILD-SIGNING.md for the third_party +# plan that fixes this properly. +GSR_DIR := third_party/go-search-replace +GSR_REPO := Automattic/go-search-replace + +# Fetch the pinned go-search-replace release into $(GSR_DIR)/-/, +# verifying every file against the sha256 in $(GSR_DIR)/MANIFEST. +# +# Those digests are the SLSA provenance subjects from the upstream release +# (go-search-replace.intoto.jsonl), not values we computed. Upstream ships the +# assets GZIPPED but attests the UNCOMPRESSED binaries, so we gunzip first and +# then hash — verified against release 0.0.11. +# +# Binaries are gitignored; only MANIFEST is tracked. Upgrade with +# `make vendor-search-replace TAG=` and commit the MANIFEST diff. +# +# ALL is the release build's entry point: bundling every platform is what makes +# the shipped tarball self-contained. +vendor-search-replace: + @tag="$${TAG:-$$(awk '$$1=="TAG"{print $$2}' $(GSR_DIR)/MANIFEST)}"; \ + if [ -z "$$tag" ]; then echo "no TAG in $(GSR_DIR)/MANIFEST" >&2; exit 1; fi; \ + if [ -n "$$ALL" ]; then \ + targets=$$(awk '/^(darwin|linux|windows)\//{print $$1}' $(GSR_DIR)/MANIFEST); \ + else \ + targets="$$($(GO) env GOOS)/$$($(GO) env GOARCH)"; \ + fi; \ + tmp=$$(mktemp -d); trap 'rm -rf "$$tmp"' EXIT; \ + for t in $$targets; do \ + os=$${t%%/*}; arch=$${t##*/}; \ + want=$$(awk -v k="$$t" '$$1==k{print $$2}' $(GSR_DIR)/MANIFEST); \ + if [ -z "$$want" ]; then echo "ERROR: $$t is not pinned in $(GSR_DIR)/MANIFEST" >&2; exit 1; fi; \ + name=go-search-replace_$${os}_$${arch}; \ + if [ "$$os" = "windows" ]; then name=$$name.exe; fi; \ + echo " fetching $$name ($$tag)"; \ + if ! gh release download "$$tag" --repo $(GSR_REPO) --pattern "$$name.gz" --dir "$$tmp" --clobber >/dev/null 2>&1; then \ + echo "ERROR: could not download $$name.gz from $(GSR_REPO)@$$tag" >&2; \ + echo " needs the gh CLI, authenticated. See docs/BUILD-SIGNING.md." >&2; exit 1; fi; \ + gunzip -f "$$tmp/$$name.gz"; \ + got=$$(shasum -a 256 "$$tmp/$$name" | cut -d' ' -f1); \ + if [ "$$got" != "$$want" ]; then \ + echo "ERROR: checksum mismatch for $$t" >&2; \ + echo " expected (from upstream SLSA provenance): $$want" >&2; \ + echo " got: $$got" >&2; \ + echo " Refusing to install. Do not bypass this." >&2; exit 1; fi; \ + out=$(GSR_DIR)/$${os}-$${arch}; mkdir -p "$$out"; \ + d=$$out/go-search-replace; if [ "$$os" = "windows" ]; then d=$$d.exe; fi; \ + mv "$$tmp/$$name" "$$d"; chmod +x "$$d"; \ + echo " verified + installed $$d"; \ + done + +search-replace-bin: + @os=$$($(GO) env GOOS); arch=$$($(GO) env GOARCH); \ + case "$$os/$$arch" in \ + darwin/arm64) f=go-search-replace-test-darwin-arm64;; \ + darwin/amd64) f=go-search-replace-test-darwin-x64;; \ + linux/amd64) f=go-search-replace-test-linux-x64;; \ + windows/amd64) f=go-search-replace-test-win32-x64.exe;; \ + *) f="";; \ + esac; \ + dest=$(BIN_DIR)/go-search-replace; \ + if [ "$$os" = "windows" ]; then dest=$$dest.exe; fi; \ + vendored=$(GSR_DIR)/$${os}-$${arch}/go-search-replace; \ + if [ "$$os" = "windows" ]; then vendored=$$vendored.exe; fi; \ + src=""; \ + if [ -f "$$vendored" ]; then src=$$vendored; \ + elif [ -n "$$f" ]; then src=__fixtures__/search-replace-binaries/$$f; fi; \ + if [ -n "$$src" ] && [ -f "$$src" ]; then \ + cp "$$src" "$$dest" && chmod +x "$$dest" && echo "bundled go-search-replace -> $$dest (from $$src)"; \ + elif [ -n "$$VIP_SEARCH_REPLACE_BIN" ]; then \ + echo "no bundled go-search-replace for $$os/$$arch; using VIP_SEARCH_REPLACE_BIN=$$VIP_SEARCH_REPLACE_BIN"; \ + elif [ -n "$$ALLOW_MISSING_SEARCH_REPLACE" ]; then \ + echo "WARNING: no go-search-replace for $$os/$$arch; search-replace, import sql --search-replace and dev-env sync sql will fail at runtime (ALLOW_MISSING_SEARCH_REPLACE set)"; \ + else \ + if [ -z "$$f" ]; then \ + echo "ERROR: no bundled go-search-replace for $$os/$$arch." >&2; \ + else \ + echo "ERROR: bundled go-search-replace fixture is missing: $$src" >&2; \ + fi; \ + echo "" >&2; \ + echo " vip-next would build, then fail at runtime on: search-replace," >&2; \ + echo " import sql --search-replace, dev-env sync sql." >&2; \ + echo "" >&2; \ + echo " Fix one of:" >&2; \ + echo " make vendor-search-replace # fetch + verify from upstream" >&2; \ + echo " VIP_SEARCH_REPLACE_BIN=/path/to/go-search-replace make build" >&2; \ + echo " ALLOW_MISSING_SEARCH_REPLACE=1 make build # build without it" >&2; \ + echo "" >&2; \ + echo " Pinned release: $(GSR_DIR)/MANIFEST" >&2; \ + exit 1; \ + fi + +# Proxy variables are scrubbed for the same reason internal/parity's +# scenarioEnvPinned and BuildParkerEnv scrub them: internal/httpproxy honours +# VIP_PROXY unconditionally and applies no loopback exemption (neither does +# Node's proxy-from-env), so a developer with the VIP SOCKS proxy exported would +# have every httptest server in the suite dialled through it. NO_PROXY is +# cleared too — with it set, the ported coveredInNoProxy suppresses a +# SOCKS_PROXY-only configuration, which would mask a real regression. +# `test-parity-unit-hostile` deliberately does the opposite and is a separate +# target; do not scrub there. +PROXY_SCRUB = VIP_PROXY= vip_proxy= SOCKS_PROXY= socks_proxy= \ + HTTPS_PROXY= https_proxy= HTTP_PROXY= http_proxy= \ + ALL_PROXY= all_proxy= NO_PROXY= no_proxy= VIP_USE_SYSTEM_PROXY= + +# The Go package list, discovered rather than hardcoded so a new top-level +# tree is picked up automatically, with node_modules removed. +# +# node_modules matters because CI now runs `npm ci` — the parity job diffs +# vip-next against the built Node CLI. An npm dependency ships real Go source, +# node_modules/flatted/golang/pkg/flatted, which lands inside this module, so a +# bare `./...` compiles and vets third-party Go pulled from the npm registry. +# (Verified on Go 1.27: `go list ./...` does include it.) +# +# The empty guard is not paranoia: `go test` with no package arguments tests +# the current directory and exits 0. A silently-empty list would look exactly +# like a passing suite, which is the failure mode this whole area exists to +# remove. +# +# -buildvcs=false keeps `go list` working when the checkout sits under another +# VCS's working copy. +GO_PKG_LIST = pkgs="$$($(GO) list -buildvcs=false ./... | grep -v '/node_modules/')"; \ + [ -n "$$pkgs" ] || { echo 'go list produced no packages; refusing to report success' >&2; exit 1; } + +test: + @$(GO_PKG_LIST); \ + $(PROXY_SCRUB) $(GO) test $$pkgs + +# The three host checks that decide whether the Node CLI can be executed. +# Shared verbatim by the warn-only and the fail-hard targets below so the two +# can never disagree about what "ready" means. Mirrors ResolveNodeVipBin in +# internal/parity/nodebin.go (see the comment on LoudSkip for why the check is +# duplicated in shell at all). +define NODE_VIP_BIN_PROBE +missing=''; \ +[ -f "$(NODE_VIP_BIN)" ] || missing="$$missing\n - $(NODE_VIP_BIN) does not exist; run 'npm run build'"; \ +[ -d "$(CURDIR)/node_modules" ] || missing="$$missing\n - $(CURDIR)/node_modules is absent; run 'npm ci'"; \ +command -v node >/dev/null 2>&1 || missing="$$missing\n - 'node' is not on PATH; install Node 22.19+ (package.json engines)"; +endef + +# Reports whether the Node CLI can be executed, and if not, exactly what is +# missing. `go test` buffers a passing package's output, so a t.Skip inside the +# suite is invisible without -v — this banner is what keeps a skipped +# differential scenario from looking like a passing one. +# +# This target WARNS and succeeds: a contributor who has never run `npm ci` must +# still be able to run `make test-parity-unit`. CI calls require-node-vip-bin +# instead, which fails. +node-vip-bin-status: + @$(NODE_VIP_BIN_PROBE) \ + if [ -z "$$missing" ]; then \ + echo "parity: Node-vs-Go differential coverage ON (NODE_VIP_BIN=$(NODE_VIP_BIN))"; \ + else \ + printf '\n%s\n' "================================================================================"; \ + printf ' WARNING: Node-vs-Go differential coverage is OFF.\n'; \ + printf ' The Node-vs-Go differential scenarios are the ONLY tests that run the real\n'; \ + printf ' Node CLI; they will SKIP. Every other scenario compares vip-next against a\n'; \ + printf ' mock.\n'; \ + printf ' Missing:%b\n' "$$missing"; \ + printf '%s\n\n' "================================================================================"; \ + fi + +# The CI counterpart of node-vip-bin-status: same probe, non-zero exit. +# +# Without this, the failure mode that made the differential worthless is +# invisible and permanent — if dist/ stops being built, every Node-vs-Go +# scenario silently skips and the job still goes green. A skipped differential +# must never be indistinguishable from a passing one in CI. +require-node-vip-bin: + @$(NODE_VIP_BIN_PROBE) \ + if [ -n "$$missing" ]; then \ + printf '\n%s\n' "================================================================================"; \ + printf ' ERROR: the Node CLI cannot be executed, so every Node-vs-Go differential\n'; \ + printf ' scenario would SKIP. In CI that is a failure, not a degradation.\n'; \ + printf ' Missing:%b\n' "$$missing"; \ + printf '%s\n\n' "================================================================================"; \ + exit 1; \ + fi; \ + echo "parity: Node-vs-Go differential coverage ON (NODE_VIP_BIN=$(NODE_VIP_BIN))" + +# Lists the scenarios whose Node-vs-Go divergence has been accepted as +# intentional, straight from the YAML that records the decision. +# +# It exists for the same reason node-vip-bin-status does: `go test` without -v +# discards a PASSING package's output, so the banner the differential writes +# when it meets a blessed divergence is invisible in a green run. A divergence +# nobody ever sees is indistinguishable from parity, and this list is the thing +# a reviewer should be arguing with. +.PHONY: blessed-drift-status +blessed-drift-status: + @names=$$(grep -l '^expected_drift:' testdata/parity/*.yaml 2>/dev/null | \ + sed 's|testdata/parity/||; s|\.yaml$$||' | sort); \ + if [ -n "$$names" ]; then \ + printf 'parity: %s scenario(s) carry an accepted Node-vs-Go divergence:\n' "$$(echo "$$names" | wc -l | tr -d ' ')"; \ + echo "$$names" | sed 's/^/ - /'; \ + printf ' Each records its reason and normalized-output signature in testdata/parity/.yaml (expected_drift).\n'; \ + fi + +# -count=1 disables the test cache: these scenarios spawn the built binaries +# and read the environment, so a cached "ok" would hide exactly the ambient +# dependence this suite is meant to detect. +test-parity-unit: node-vip-bin-status blessed-drift-status + NODE_VIP_BIN="$(NODE_VIP_BIN)" \ + $(GO) test -tags=parity -count=1 ./internal/parity/... + +# Proof that the fixture suite is ambient-independent (see internal/parity/env.go). +# Exports credentials, an API host, and proxies that would break or falsely +# satisfy scenarios if any of them leaked into a subprocess; results MUST be +# identical to `make test-parity-unit`. Run both after touching the harness. +# +# NODE_VIP_BIN is passed through deliberately: the Node-vs-Go scenario must +# stay ambient-independent too. +test-parity-unit-hostile: + VIP_TOKEN_OVERRIDE=hostile.ambient.token \ + WPVIP_DEPLOY_TOKEN=hostile-ambient-deploy-token \ + API_HOST=https://hostile.invalid \ + HTTP_PROXY=http://127.0.0.1:9 HTTPS_PROXY=http://127.0.0.1:9 ALL_PROXY=socks5://127.0.0.1:9 \ + http_proxy=http://127.0.0.1:9 https_proxy=http://127.0.0.1:9 all_proxy=socks5://127.0.0.1:9 \ + VIP_PROXY=socks5://127.0.0.1:9 SOCKS_PROXY=socks5://127.0.0.1:9 VIP_USE_SYSTEM_PROXY=1 \ + NODE_ENV=production DO_NOT_TRACK=0 NO_COLOR=1 DEBUG='*' \ + XDG_DATA_HOME=/nonexistent/vip-parity-hostile \ + VIP_SEARCH_REPLACE_BIN=/nonexistent/go-search-replace \ + $(MAKE) --no-print-directory test-parity-unit + +test-parity: + npm run build + @$(MAKE) --no-print-directory build + NODE_VIP_BIN="$(NODE_VIP_BIN)" \ + GO_VIP_BIN=$(CURDIR)/bin/vip-next \ + $(GO) test -tags='parity parker_parity' ./internal/parity \ + -run '^TestLocalParkerParity$$' -count=1 -v + +lint: + @$(GO_PKG_LIST); \ + $(GO) vet $$pkgs + +tidy: + $(GO) mod tidy + +clean: + rm -rf $(BIN_DIR) + +# Regenerate internal/gql/generated.go from schema.gql + operations/*.graphql. +tidy-gql: + cd internal/gql && $(GO) run github.com/Khan/genqlient + +# Fail if internal/gql/generated.go on disk doesn't match what genqlient +# would produce from schema.gql + operations/*.graphql. The recipe never +# leaves the on-disk file altered: it stashes the contributor's copy to a +# temp file, runs genqlient (which writes to the configured generated.go), +# compares, and ALWAYS restores the stashed copy via a shell trap -- so +# even on errors or interrupts the working tree is left exactly as the +# contributor had it. (genqlient v0.8.1 does not support --output, so we +# can't redirect codegen directly; the trap-based restore is reliable +# because it always uses the saved file, unlike the prior recipe which +# restored from the post-regen file.) +verify-gql-stale: + @cd internal/gql && \ + stash=$$(mktemp) && fresh=$$(mktemp) && \ + trap 'mv -f "$$stash" generated.go 2>/dev/null; rm -f "$$fresh"' EXIT INT TERM HUP; \ + cp generated.go "$$stash" && \ + $(GO) run github.com/Khan/genqlient && \ + cp generated.go "$$fresh" && \ + if cmp -s "$$stash" "$$fresh"; then \ + echo "internal/gql/generated.go is up to date"; \ + else \ + echo ""; \ + echo "ERROR: internal/gql/generated.go is stale relative to schema.gql / operations/*.graphql."; \ + echo "Run 'make tidy-gql' and commit the regenerated file."; \ + exit 1; \ + fi diff --git a/make.ps1 b/make.ps1 new file mode 100644 index 000000000..656ae68d8 --- /dev/null +++ b/make.ps1 @@ -0,0 +1,155 @@ +<# +.SYNOPSIS + PowerShell port of the Makefile for building/testing vip-next on native Windows. + (On macOS/Linux/WSL use the Makefile: `make build`, `make test`, ...) + +.USAGE + powershell -ExecutionPolicy Bypass -File .\make.ps1 + # or, in a session that already allows scripts: + .\make.ps1 build + + Targets: + build Build bin\vip-next.exe (version-stamped) + bundle go-search-replace.exe + search-replace-bin Bundle the host go-search-replace binary next to vip-next (called by build) + test go test ./... (the whole suite) + test-parity go test -tags=parity ./internal/parity/... + lint go vet ./... + tidy go mod tidy + tidy-gql Regenerate internal/gql/generated.go via genqlient + verify-gql-stale Fail if generated.go is stale vs schema/operations (working tree untouched) + clean Remove bin\ + + Notes: + * Requires Go 1.27. `encoding/json/v2` is part of the standard library. + * If running scripts is blocked, prefix with: powershell -ExecutionPolicy Bypass -File .\make.ps1 ... +#> + +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [ValidateSet('build', 'search-replace-bin', 'test', 'test-parity', 'lint', 'tidy', 'tidy-gql', 'verify-gql-stale', 'clean', 'help')] + [string]$Target = 'build' +) + +$ErrorActionPreference = 'Stop' + +# --- config (mirrors the Makefile vars) --- +$GO = if ($env:GO) { $env:GO } else { 'go' } +$BinDir = 'bin' +$BinName = 'vip-next.exe' +$BinPath = Join-Path $BinDir $BinName + +# Run a Go command and stop on a non-zero exit (PowerShell doesn't do this for native exes by default). +function Invoke-Go { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$GoArgs) + Write-Host "+ $GO $($GoArgs -join ' ')" -ForegroundColor DarkGray + & $GO @GoArgs + if ($LASTEXITCODE -ne 0) { throw "go $($GoArgs[0]) failed (exit $LASTEXITCODE)" } +} + +# LDFLAGS: version/commit from git, with the same fallbacks as the Makefile. +function Get-LdFlags { + # Windows PowerShell 5.1 promotes native git stderr ("not a git repository") to a + # terminating error under this script's $ErrorActionPreference='Stop' -- even with 2>$null -- + # which kills the intended dev/unknown fallback when building outside a git checkout + # (e.g. from a source tarball). Scope the preference down for the git probes below. + $ErrorActionPreference = 'SilentlyContinue' + $version = (& git describe --tags --always --dirty 2>$null) + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($version)) { $version = 'dev' } + $commit = (& git rev-parse --short HEAD 2>$null) + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($commit)) { $commit = 'unknown' } + $pkg = 'github.com/Automattic/vip/internal/version' + return "-s -w -X $pkg.Version=$version -X $pkg.Commit=$commit" +} + +function Target-Build { + New-Item -ItemType Directory -Force -Path $BinDir | Out-Null + $env:CGO_ENABLED = '0' + $ldflags = Get-LdFlags + Invoke-Go build '-buildvcs=false' '-trimpath' '-ldflags' $ldflags '-o' $BinPath './cmd/vip-next' + Write-Host "built $BinPath" -ForegroundColor Green + Target-SearchReplaceBin +} + +# Bundle the host's go-search-replace binary next to vip-next so `import sql` +# (--search-replace) and `dev-env sync sql` resolve it without a runtime download. +# (Not needed for the dev-env hosts feature, but kept for Makefile parity.) +function Target-SearchReplaceBin { + $os = (& $GO env GOOS).Trim() + $arch = (& $GO env GOARCH).Trim() + $fixture = switch ("$os/$arch") { + 'darwin/arm64' { 'go-search-replace-test-darwin-arm64' } + 'darwin/amd64' { 'go-search-replace-test-darwin-x64' } + 'linux/amd64' { 'go-search-replace-test-linux-x64' } + 'windows/amd64' { 'go-search-replace-test-win32-x64.exe' } + default { $null } + } + if (-not $fixture) { + Write-Host "no bundled go-search-replace for $os/$arch; set VIP_SEARCH_REPLACE_BIN to use sync/search-replace" -ForegroundColor Yellow + return + } + $src = Join-Path '__fixtures__/search-replace-binaries' $fixture + $dest = Join-Path $BinDir ('go-search-replace' + $(if ($os -eq 'windows') { '.exe' } else { '' })) + if (Test-Path $src) { + Copy-Item -Force $src $dest + Write-Host "bundled go-search-replace -> $dest" -ForegroundColor Green + } + else { + Write-Host "fixture $src missing; set VIP_SEARCH_REPLACE_BIN to use sync/search-replace" -ForegroundColor Yellow + } +} + +function Target-Test { Invoke-Go test './...' } +function Target-TestParity { Invoke-Go test '-tags=parity' './internal/parity/...' } +function Target-Lint { Invoke-Go vet './...' } +function Target-Tidy { Invoke-Go mod tidy } +function Target-Clean { if (Test-Path $BinDir) { Remove-Item -Recurse -Force $BinDir }; Write-Host "cleaned $BinDir" } + +# Regenerate internal/gql/generated.go from schema.gql + operations/*.graphql. +function Target-TidyGql { + Push-Location internal/gql + try { Invoke-Go run 'github.com/Khan/genqlient' } + finally { Pop-Location } +} + +# Fail if internal/gql/generated.go is stale. Like the Makefile, this NEVER leaves +# the on-disk file altered: it saves the contributor's copy, runs genqlient (which +# overwrites generated.go), compares, and ALWAYS restores the saved copy. +function Target-VerifyGqlStale { + Push-Location internal/gql + $stash = [System.IO.Path]::GetTempFileName() + try { + Copy-Item -Force 'generated.go' $stash + Invoke-Go run 'github.com/Khan/genqlient' + $same = $null -eq (Compare-Object (Get-Content $stash) (Get-Content 'generated.go')) + if ($same) { + Write-Host 'internal/gql/generated.go is up to date' -ForegroundColor Green + } + else { + Write-Host '' + Write-Host 'ERROR: internal/gql/generated.go is stale relative to schema.gql / operations/*.graphql.' -ForegroundColor Red + Write-Host "Run '.\make.ps1 tidy-gql' and commit the regenerated file." + throw 'generated.go is stale' + } + } + finally { + Copy-Item -Force $stash 'generated.go' # always restore the contributor's copy + Remove-Item -Force $stash -ErrorAction SilentlyContinue + Pop-Location + } +} + +function Target-Help { Get-Help $PSCommandPath -Detailed } + +switch ($Target) { + 'build' { Target-Build } + 'search-replace-bin' { Target-SearchReplaceBin } + 'test' { Target-Test } + 'test-parity' { Target-TestParity } + 'lint' { Target-Lint } + 'tidy' { Target-Tidy } + 'tidy-gql' { Target-TidyGql } + 'verify-gql-stale' { Target-VerifyGqlStale } + 'clean' { Target-Clean } + 'help' { Target-Help } +} diff --git a/third_party/go-search-replace/MANIFEST b/third_party/go-search-replace/MANIFEST new file mode 100644 index 000000000..71ba9ec1b --- /dev/null +++ b/third_party/go-search-replace/MANIFEST @@ -0,0 +1,34 @@ +# go-search-replace — pinned upstream release +# +# vip-next shells out to this binary; it never reimplements it. See +# internal/searchreplace/searchreplace.go (ResolveBinary) and +# docs/BUILD-SIGNING.md. +# +# Source: https://github.com/Automattic/go-search-replace/releases +# +# The digests below are NOT computed by us. They are the subject digests from +# the release's SLSA provenance attestation (go-search-replace.intoto.jsonl), +# produced by: +# +# https://github.com/Automattic/go-search-replace/.github/workflows/release.yml@refs/tags/0.0.11 +# +# IMPORTANT: upstream ships each asset gzipped (.gz) but the provenance +# subjects are the UNCOMPRESSED binaries. Verify by gunzipping first, then +# sha256. `make vendor-search-replace` does exactly that and refuses to install +# anything that does not match. +# +# To upgrade: `make vendor-search-replace TAG=` rewrites this file, so +# an upgrade is one reviewable commit whose diff is the tag and the digests. +# +# Format: / + +TAG 0.0.11 + +darwin/amd64 84c06c7372f8485ee62d51f0ae1cfa580432830e2d756d728b8e8b3415fd49eb +darwin/arm64 dad680dbd24af5c455d2af49ba97563f07f5f86f9b609be8c91abd38c4772448 +linux/386 de65a0bcdc0907c5a5ede2804e602330819e497258679dad1676a8931d484a06 +linux/amd64 d5b5a3a5e9b76bf5bd07d579ae931f192ba01236f92ef54898b8f0d7d5548109 +linux/arm64 df32ee7aa1bc611a6bfe6bc2945abc0f755219da441299ffd2d9add64da95bda +windows/386 92d32fbcb6ea0548f8b70f8d6e57d0efd01b849800f3d814c369a8f65b19583b +windows/amd64 db7b116593b5369e033c43bd46a207e8fc9761ef87c5f16cbb3abd34c8d8a858 +windows/arm64 e26c342cc95bc0a28656d30b52f2f1c1e1c034a6d672690bdb7e9ffcc334eb4f From b549d7ec150c827e18e503e8e27f95ddbea9dfc5 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 20 Aug 2026 18:36:31 -0500 Subject: [PATCH 03/32] feat(go): output, TUI and exit-code primitives Ported from vip-cli-golang@72ef2f89 (feature/go-rewrite). --- internal/exit/exit.go | 94 +++++ internal/exit/exit_test.go | 119 ++++++ internal/nodeflags/nodeflags.go | 130 +++++++ internal/nodeflags/nodeflags_test.go | 138 +++++++ internal/nodeflags/optionalvalue.go | 123 ++++++ internal/nodeflags/optionalvalue_test.go | 158 ++++++++ internal/output/csv.go | 178 +++++++++ internal/output/fields.go | 22 ++ internal/output/fields_test.go | 21 + internal/output/ids.go | 26 ++ internal/output/ids_test.go | 32 ++ internal/output/json.go | 100 +++++ internal/output/keyvalue.go | 35 ++ internal/output/keyvalue_block.go | 77 ++++ internal/output/keyvalue_block_test.go | 95 +++++ internal/output/keyvalue_test.go | 35 ++ internal/output/ordered.go | 44 +++ internal/output/ordered_test.go | 42 ++ internal/output/output.go | 62 +++ internal/output/output_test.go | 382 ++++++++++++++++++ internal/output/table.go | 231 +++++++++++ internal/output/table_layout.go | 472 +++++++++++++++++++++++ internal/output/table_layout_test.go | 107 +++++ internal/output/table_layout_tty_test.go | 63 +++ internal/output/text.go | 24 ++ internal/output/text_test.go | 31 ++ internal/output/typename.go | 35 ++ internal/output/typename_test.go | 48 +++ internal/poll/poll.go | 85 ++++ internal/poll/poll_test.go | 136 +++++++ internal/polling/polling.go | 97 +++++ internal/polling/polling_test.go | 59 +++ internal/redact/redact.go | 99 +++++ internal/redact/redact_test.go | 80 ++++ internal/tui/progress.go | 153 ++++++++ internal/tui/progress_test.go | 89 +++++ internal/tui/progress_tracker.go | 299 ++++++++++++++ internal/tui/progress_tracker_test.go | 155 ++++++++ internal/version/version.go | 14 + internal/version/version_test.go | 25 ++ 40 files changed, 4215 insertions(+) create mode 100644 internal/exit/exit.go create mode 100644 internal/exit/exit_test.go create mode 100644 internal/nodeflags/nodeflags.go create mode 100644 internal/nodeflags/nodeflags_test.go create mode 100644 internal/nodeflags/optionalvalue.go create mode 100644 internal/nodeflags/optionalvalue_test.go create mode 100644 internal/output/csv.go create mode 100644 internal/output/fields.go create mode 100644 internal/output/fields_test.go create mode 100644 internal/output/ids.go create mode 100644 internal/output/ids_test.go create mode 100644 internal/output/json.go create mode 100644 internal/output/keyvalue.go create mode 100644 internal/output/keyvalue_block.go create mode 100644 internal/output/keyvalue_block_test.go create mode 100644 internal/output/keyvalue_test.go create mode 100644 internal/output/ordered.go create mode 100644 internal/output/ordered_test.go create mode 100644 internal/output/output.go create mode 100644 internal/output/output_test.go create mode 100644 internal/output/table.go create mode 100644 internal/output/table_layout.go create mode 100644 internal/output/table_layout_test.go create mode 100644 internal/output/table_layout_tty_test.go create mode 100644 internal/output/text.go create mode 100644 internal/output/text_test.go create mode 100644 internal/output/typename.go create mode 100644 internal/output/typename_test.go create mode 100644 internal/poll/poll.go create mode 100644 internal/poll/poll_test.go create mode 100644 internal/polling/polling.go create mode 100644 internal/polling/polling_test.go create mode 100644 internal/redact/redact.go create mode 100644 internal/redact/redact_test.go create mode 100644 internal/tui/progress.go create mode 100644 internal/tui/progress_test.go create mode 100644 internal/tui/progress_tracker.go create mode 100644 internal/tui/progress_tracker_test.go create mode 100644 internal/version/version.go create mode 100644 internal/version/version_test.go diff --git a/internal/exit/exit.go b/internal/exit/exit.go new file mode 100644 index 000000000..5251c5005 --- /dev/null +++ b/internal/exit/exit.go @@ -0,0 +1,94 @@ +// Package exit owns process termination. +// +// WithError prints a user-facing error to stderr and exits 1. +// WithCode exits with a specific code (for parity with the Node binary's +// per-command exit conventions). +// +// RegisterErrorHook lets the telemetry layer record errors before exit; +// in M1 the hook is a no-op. M2 wires telemetry.TrackError into it. +package exit + +import ( + "errors" + "fmt" + "io" + "os" +) + +// alreadyPrinted marks an error whose user-facing message was deliberately +// written by the command itself. The process must still fail and telemetry +// must still observe it, but the shared exit path must not print it again. +type alreadyPrinted interface { + AlreadyPrinted() bool +} + +type handledError struct{ err error } + +func (e handledError) Error() string { return e.err.Error() } +func (e handledError) Unwrap() error { return e.err } +func (handledError) AlreadyPrinted() bool { return true } + +// Handled preserves a command error's non-zero exit while marking its message +// as already rendered for the user. +func Handled(err error) error { + if err == nil { + return nil + } + return handledError{err: err} +} + +type exitFunc func(int) +type errorHook func(error) + +// The package-level vars below are intentionally unsynchronized. +// RegisterErrorHook must be called once during single-threaded init, +// before any goroutine that may call WithError or WithCode is started. +// A signal handler that races with the main goroutine on these vars +// is unsupported in M1; M2 will revisit if needed. +var ( + stderr io.Writer = os.Stderr + exiter exitFunc = os.Exit + errHook errorHook = func(error) {} +) + +func WithError(err error) { + writeAndExit(stderr, exiter, errHook, err) +} + +func WithCode(code int, err error) { + writeAndExitCode(stderr, exiter, errHook, code, err) +} + +func RegisterErrorHook(h errorHook) { + if h == nil { + errHook = func(error) {} + return + } + errHook = h +} + +func writeAndExitCode(w io.Writer, ex exitFunc, hook errorHook, code int, err error) { + if err != nil { + hook(err) + if !isAlreadyPrinted(err) { + fmt.Fprintf(w, "Error: %s\n", err.Error()) + } + } + ex(code) +} + +func writeAndExit(w io.Writer, ex exitFunc, hook errorHook, err error) { + if err == nil { + return + } + hook(err) + if !isAlreadyPrinted(err) { + fmt.Fprintf(w, "Error: %s\n", err.Error()) + } + ex(1) +} + +func isAlreadyPrinted(err error) bool { + var marked alreadyPrinted + return errors.As(err, &marked) && marked.AlreadyPrinted() +} diff --git a/internal/exit/exit_test.go b/internal/exit/exit_test.go new file mode 100644 index 000000000..ae2284860 --- /dev/null +++ b/internal/exit/exit_test.go @@ -0,0 +1,119 @@ +package exit + +import ( + "bytes" + "errors" + "testing" +) + +func TestWriteErrorFormatsMessageAndCallsHook(t *testing.T) { + var buf bytes.Buffer + var calledCode int + exiter := func(code int) { calledCode = code } + hookCalled := false + hook := func(err error) { hookCalled = true } + + writeAndExit(&buf, exiter, hook, errors.New("boom")) + + if calledCode != 1 { + t.Errorf("exit code = %d, want 1", calledCode) + } + if !hookCalled { + t.Error("hook was not called") + } + if got := buf.String(); got != "Error: boom\n" { + t.Errorf("stderr = %q, want %q", got, "Error: boom\n") + } +} + +func TestWriteErrorExitsWithoutDuplicatingAnAlreadyPrintedMessage(t *testing.T) { + var buf bytes.Buffer + calledCode := -1 + hookCalled := false + + writeAndExit( + &buf, + func(code int) { calledCode = code }, + func(error) { hookCalled = true }, + Handled(errors.New("message already shown on stdout")), + ) + + if calledCode != 1 { + t.Errorf("exit code = %d, want 1", calledCode) + } + if !hookCalled { + t.Error("hook must still observe the failure") + } + if buf.Len() != 0 { + t.Errorf("already-printed error must not be duplicated on stderr; got %q", buf.String()) + } +} + +func TestWriteErrorNilErrorNoOps(t *testing.T) { + var buf bytes.Buffer + called := false + exiter := func(int) { called = true } + hook := func(error) {} + + writeAndExit(&buf, exiter, hook, nil) + + if called { + t.Error("exiter must not be called for nil error") + } + if buf.Len() != 0 { + t.Errorf("stderr should be empty, got %q", buf.String()) + } +} + +func TestRegisterHookReplaces(t *testing.T) { + original := errHook + t.Cleanup(func() { errHook = original }) + + called := 0 + RegisterErrorHook(func(error) { called++ }) + + errHook(errors.New("x")) + if called != 1 { + t.Errorf("hook called %d times, want 1", called) + } +} + +func TestWithCodeWithError(t *testing.T) { + var buf bytes.Buffer + var calledCode int + exiter := func(code int) { calledCode = code } + hookCalled := false + hook := func(err error) { hookCalled = true } + + writeAndExitCode(&buf, exiter, hook, 42, errors.New("specific failure")) + + if calledCode != 42 { + t.Errorf("exit code = %d, want 42", calledCode) + } + if !hookCalled { + t.Error("hook was not called when err != nil") + } + if got := buf.String(); got != "Error: specific failure\n" { + t.Errorf("stderr = %q, want %q", got, "Error: specific failure\n") + } +} + +func TestWithCodeNilErrorStillExits(t *testing.T) { + var buf bytes.Buffer + var calledCode int = -1 + exiter := func(code int) { calledCode = code } + hookCalled := false + hook := func(err error) { hookCalled = true } + + writeAndExitCode(&buf, exiter, hook, 0, nil) + + if calledCode != 0 { + t.Errorf("exit code = %d, want 0", calledCode) + } + if hookCalled { + t.Error("hook must not be called when err == nil") + } + if buf.Len() != 0 { + t.Errorf("stderr should be empty, got %q", buf.String()) + } +} diff --git a/internal/nodeflags/nodeflags.go b/internal/nodeflags/nodeflags.go new file mode 100644 index 000000000..fce4dc5c4 --- /dev/null +++ b/internal/nodeflags/nodeflags.go @@ -0,0 +1,130 @@ +// Package nodeflags ports the option-value grammar that the Node CLI applies +// to flag values before a handler ever sees them. +// +// Node registers every non-boolean option with commander as `--name [value]` +// (src/lib/cli/command.js:111-114) and hands the raw token to a per-option +// parse function. The parse functions live in +// src/lib/dev-environment/dev-environment-cli.ts and are ported here verbatim, +// including their edge cases. Nothing in this package prompts, validates +// against the network, or touches disk — it is pure value coercion plus the +// argv reshaping that gives cobra commander's optional-value lookahead. +package nodeflags + +import "strings" + +// FalseOptions / TrueOptions mirror dev-environment-cli.ts:924-925. +var ( + FalseOptions = []string{"false", "no", "n", "0"} + TrueOptions = []string{"true", "yes", "y", "1"} +) + +func containsFold(list []string, v string) bool { + lower := strings.ToLower(v) + for _, x := range list { + if x == lower { + return true + } + } + return false +} + +// ProcessBooleanOption ports processBooleanOption (dev-environment-cli.ts:939). +// +// if ( ! value ) { return false; } +// return ! FALSE_OPTIONS.includes( value.toString().toLowerCase() ); +// +// Two consequences worth stating because they are easy to "fix" by accident: +// +// - An unrecognized value is TRUE, not an error. `--xdebug maybe` enables +// Xdebug in Node, so it must enable it here. +// - The empty string is false: JS short-circuits on the falsy value +// before the FALSE_OPTIONS lookup ever runs. +func ProcessBooleanOption(value string) bool { + if value == "" { + return false + } + return !containsFold(FalseOptions, value) +} + +// MediaRedirectDomainError is the UserError message Node throws when the +// media redirect domain is given a truthy word instead of a domain +// (dev-environment-cli.ts:957). +const MediaRedirectDomainError = "Media redirect domain must be a domain name or an URL" + +type mediaRedirectError struct{} + +func (mediaRedirectError) Error() string { return MediaRedirectDomainError } + +// ProcessMediaRedirectDomainOption ports processMediaRedirectDomainOption +// (dev-environment-cli.ts:948). A FALSE_OPTIONS value DISABLES the redirect +// (returns ""); a TRUE_OPTIONS value is a user error; anything else is the +// domain itself. +func ProcessMediaRedirectDomainOption(value string) (string, error) { + if containsFold(FalseOptions, value) { + return "", nil + } + if containsFold(TrueOptions, value) { + return "", mediaRedirectError{} + } + return value, nil +} + +// Kind distinguishes the two arms of Node's `string | boolean` return type. +type Kind int + +const ( + KindBool Kind = iota + KindString +) + +// StringOrBool is the Go shape of Node's `string | boolean` union. +type StringOrBool struct { + Kind Kind + Bool bool + String string +} + +// ProcessStringOrBooleanOption ports processStringOrBooleanOption +// (dev-environment-cli.ts:963). Used by `dev-env create --multisite`, whose +// accepted values are "y"/"subdirectory"/"false". +func ProcessStringOrBooleanOption(value string) StringOrBool { + if value == "" || containsFold(FalseOptions, value) { + return StringOrBool{Kind: KindBool, Bool: false} + } + if containsFold(TrueOptions, value) { + return StringOrBool{Kind: KindBool, Bool: true} + } + return StringOrBool{Kind: KindString, String: value} +} + +// ProcessSlug ports processSlug (dev-environment-cli.ts:979): coerce to a +// string, then toLowerCase. Every Node dev-env bin that +// registers --slug passes this as the option's parse function, so the slug is +// lowercased before it ever reaches the on-disk environment path or the +// compose project name. +func ProcessSlug(value string) string { return strings.ToLower(value) } + +// Component is the Go shape of Node's LocalComponent | ImageComponent +// (dev-environment-cli.ts:217-227). +type Component struct { + Mode string // "local" or "image" + Dir string // set when Mode == "local" + Tag string // set when Mode == "image"; "" mirrors Node's `undefined` +} + +// ProcessComponentOptionInput ports processComponentOptionInput +// (dev-environment-cli.ts:237). The "naive check" for a local path is Node's +// own wording: any value containing a forward or back slash is a directory +// when allowLocal is set. "demo" and "image" resolve to the default image +// (Node returns tag `undefined`), which is why `--app-code demo` must NOT +// become a literal bind-mount path. +func ProcessComponentOptionInput(param string, allowLocal bool) Component { + if allowLocal && strings.ContainsAny(param, `/\`) { + return Component{Mode: "local", Dir: param} + } + tag := param + if param == "demo" || param == "image" { + tag = "" + } + return Component{Mode: "image", Tag: tag} +} diff --git a/internal/nodeflags/nodeflags_test.go b/internal/nodeflags/nodeflags_test.go new file mode 100644 index 000000000..d6883b682 --- /dev/null +++ b/internal/nodeflags/nodeflags_test.go @@ -0,0 +1,138 @@ +package nodeflags + +import "testing" + +// Node: src/lib/dev-environment/dev-environment-cli.ts:939-946 +// +// export function processBooleanOption( value: unknown ): boolean { +// if ( ! value ) { return false; } +// return ! FALSE_OPTIONS.includes( value.toString().toLowerCase() ); +// } +// +// FALSE_OPTIONS = [ 'false', 'no', 'n', '0' ] (line 924). +func TestProcessBooleanOption(t *testing.T) { + cases := []struct { + in string + want bool + }{ + // FALSE_OPTIONS, case-insensitive. + {"false", false}, {"FALSE", false}, + {"no", false}, {"No", false}, + {"n", false}, {"N", false}, + {"0", false}, + // TRUE_OPTIONS. + {"true", true}, {"TRUE", true}, + {"yes", true}, {"y", true}, {"Y", true}, {"1", true}, + // Node does NOT error on unrecognized values: anything not in + // FALSE_OPTIONS is true. + {"maybe", true}, {"nope", true}, {"00", true}, {" n", true}, + // `! value` short-circuit: the empty string is falsy in JS. + {"", false}, + } + for _, c := range cases { + if got := ProcessBooleanOption(c.in); got != c.want { + t.Errorf("ProcessBooleanOption(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +// Node: dev-environment-cli.ts:948-961. +func TestProcessMediaRedirectDomainOption(t *testing.T) { + for _, in := range []string{"false", "no", "n", "0", "N", "No"} { + got, err := ProcessMediaRedirectDomainOption(in) + if err != nil { + t.Errorf("ProcessMediaRedirectDomainOption(%q) errored: %v", in, err) + } + if got != "" { + t.Errorf("ProcessMediaRedirectDomainOption(%q) = %q, want \"\" (disabled)", in, got) + } + } + for _, in := range []string{"true", "yes", "y", "1", "Y"} { + if _, err := ProcessMediaRedirectDomainOption(in); err == nil { + t.Errorf("ProcessMediaRedirectDomainOption(%q): want UserError, got nil", in) + } else if err.Error() != "Media redirect domain must be a domain name or an URL" { + t.Errorf("ProcessMediaRedirectDomainOption(%q) error = %q", in, err) + } + } + // Anything else passes through verbatim, including the empty string + // (Node: `( value ?? '' ).toString()` then falls through the two guards). + for _, in := range []string{"example.go-vip.co", "https://example.com", ""} { + got, err := ProcessMediaRedirectDomainOption(in) + if err != nil || got != in { + t.Errorf("ProcessMediaRedirectDomainOption(%q) = (%q, %v), want (%q, nil)", in, got, err, in) + } + } +} + +// Node: dev-environment-cli.ts:963-977. +func TestProcessStringOrBooleanOption(t *testing.T) { + cases := []struct { + in string + wantVal string + wantBool bool + wantKind Kind + }{ + {"", "", false, KindBool}, + {"false", "", false, KindBool}, + {"n", "", false, KindBool}, + {"0", "", false, KindBool}, + {"true", "", true, KindBool}, + {"y", "", true, KindBool}, + {"1", "", true, KindBool}, + {"subdirectory", "subdirectory", false, KindString}, + } + for _, c := range cases { + got := ProcessStringOrBooleanOption(c.in) + if got.Kind != c.wantKind || got.Bool != c.wantBool || got.String != c.wantVal { + t.Errorf("ProcessStringOrBooleanOption(%q) = %+v, want kind=%v bool=%v string=%q", + c.in, got, c.wantKind, c.wantBool, c.wantVal) + } + } +} + +// Node: dev-environment-cli.ts:979-982 — coerce to string, then toLowerCase. +func TestProcessSlug(t *testing.T) { + cases := map[string]string{ + "Example-Site": "example-site", + "MYSITE": "mysite", + "already": "already", + "": "", + "Mixed_Case-1": "mixed_case-1", + } + for in, want := range cases { + if got := ProcessSlug(in); got != want { + t.Errorf("ProcessSlug(%q) = %q, want %q", in, got, want) + } + } +} + +// Node: dev-environment-cli.ts:229-255. +func TestProcessComponentOptionInput(t *testing.T) { + cases := []struct { + param string + allowLocal bool + wantMode string + wantDir string + wantTag string + }{ + // allowLocal + a path separator => local. + {"/Users/x/repo", true, "local", "/Users/x/repo", ""}, + {`C:\repo`, true, "local", `C:\repo`, ""}, + {"./repo", true, "local", "./repo", ""}, + // No separator => image, tag = param. + {"6.4", true, "image", "", "6.4"}, + {"latest", false, "image", "", "latest"}, + // "demo"/"image" => image with NO tag (Node returns undefined). + {"demo", true, "image", "", ""}, + {"image", true, "image", "", ""}, + // allowLocal=false never yields local, even with a separator. + {"/Users/x/repo", false, "image", "", "/Users/x/repo"}, + } + for _, c := range cases { + got := ProcessComponentOptionInput(c.param, c.allowLocal) + if got.Mode != c.wantMode || got.Dir != c.wantDir || got.Tag != c.wantTag { + t.Errorf("ProcessComponentOptionInput(%q, %v) = %+v, want mode=%s dir=%q tag=%q", + c.param, c.allowLocal, got, c.wantMode, c.wantDir, c.wantTag) + } + } +} diff --git a/internal/nodeflags/optionalvalue.go b/internal/nodeflags/optionalvalue.go new file mode 100644 index 000000000..34b2fd48d --- /dev/null +++ b/internal/nodeflags/optionalvalue.go @@ -0,0 +1,123 @@ +package nodeflags + +import ( + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// optionalValueAnnotation marks a flag as one of Node's `--name [value]` +// optional-value options. +const optionalValueAnnotation = "vip:optional-value" + +// MarkOptionalValue gives the named flags commander's optional-value grammar: +// the bare form takes noOptDefVal, an `=value` form takes that value, and a +// following non-option token is consumed as the value (see +// NormalizeOptionalValues, which supplies the lookahead pflag lacks). +// +// Node registers every non-boolean option as `--name [value]` +// (src/lib/cli/command.js:111-114). vip-next opts in only where the bare form +// carries meaning — the dev-env service toggles and --multisite — because +// elsewhere ("--slug" with no value) Node's bare form yields the boolean +// `true`, which no handler can use. +func MarkOptionalValue(cmd *cobra.Command, noOptDefVal string, names ...string) { + for _, name := range names { + f := cmd.Flags().Lookup(name) + if f == nil { + continue + } + f.NoOptDefVal = noOptDefVal + if f.Annotations == nil { + f.Annotations = map[string][]string{} + } + f.Annotations[optionalValueAnnotation] = []string{"true"} + } +} + +func isOptionalValue(f *pflag.Flag) bool { + return f != nil && len(f.Annotations[optionalValueAnnotation]) > 0 +} + +// isOptionToken ports Node's isOptionToken (src/lib/cli/command.js:129-131): +// a lone "-" is a value, everything else starting with "-" is an option. +func isOptionToken(arg string) bool { return arg != "-" && strings.HasPrefix(arg, "-") } + +// NormalizeOptionalValues rewrites argv so cobra sees `--flag=value` wherever +// commander would have consumed the following token as an optional value. +// +// pflag has no equivalent of commander's optional-value lookahead: once a flag +// carries NoOptDefVal, `-p n` sets the flag to NoOptDefVal and leaves "n" as a +// stray positional. That is exactly the inverted-flag bug this fixes — in Node +// `-p n` DISABLES phpMyAdmin. Rewriting to `-p=n` before cobra parses restores +// commander's grammar without patching pflag. +// +// The rewrite is scoped to the command argv actually targets, so a flag name +// that is optional-value on one command cannot change parsing on another. +// Commands with DisableFlagParsing (vip wp) and everything after a `--` +// terminator are passed through verbatim, matching commander, which stops +// option processing at `--`. +func NormalizeOptionalValues(root *cobra.Command, argv []string) []string { + target, _, err := root.Find(argv) + if err != nil || target == nil || target.DisableFlagParsing { + return argv + } + // Merge inherited persistent flags so an optional-value flag declared on a + // parent is honored on the leaf. + flags := target.Flags() + flags.AddFlagSet(target.InheritedFlags()) + + longs := map[string]*pflag.Flag{} + shorts := map[string]*pflag.Flag{} + flags.VisitAll(func(f *pflag.Flag) { + if !isOptionalValue(f) { + return + } + longs[f.Name] = f + if f.Shorthand != "" { + shorts[f.Shorthand] = f + } + }) + if len(longs) == 0 { + return argv + } + + out := make([]string, 0, len(argv)) + for i := 0; i < len(argv); i++ { + arg := argv[i] + if arg == "--" { + out = append(out, argv[i:]...) + break + } + + switch { + case strings.HasPrefix(arg, "--") && !strings.Contains(arg, "="): + if _, ok := longs[arg[2:]]; !ok { + out = append(out, arg) + continue + } + case len(arg) >= 2 && arg[0] == '-' && arg[1] != '-' && !strings.Contains(arg, "="): + if _, ok := shorts[arg[1:2]]; !ok { + out = append(out, arg) + continue + } + // `-pn`: commander's _combineFlagAndOptionalValue treats the + // remainder of the token as the value. + if len(arg) > 2 { + out = append(out, arg[:2]+"="+arg[2:]) + continue + } + default: + out = append(out, arg) + continue + } + + if i+1 < len(argv) && !isOptionToken(argv[i+1]) { + out = append(out, arg+"="+argv[i+1]) + i++ + continue + } + out = append(out, arg) + } + return out +} diff --git a/internal/nodeflags/optionalvalue_test.go b/internal/nodeflags/optionalvalue_test.go new file mode 100644 index 000000000..8d1bfc845 --- /dev/null +++ b/internal/nodeflags/optionalvalue_test.go @@ -0,0 +1,158 @@ +package nodeflags + +import ( + "slices" + "testing" + + "github.com/spf13/cobra" +) + +// testTree mirrors the shape the real dev-env tree has: a parent, a leaf with +// two optional-value flags plus one ordinary value flag, and a +// DisableFlagParsing leaf (like `vip wp`). +func testTree() *cobra.Command { + root := &cobra.Command{Use: "root"} + + leaf := &cobra.Command{Use: "create", Run: func(*cobra.Command, []string) {}} + leaf.Flags().StringP("phpmyadmin", "p", "", "") + leaf.Flags().StringP("xdebug", "x", "", "") + leaf.Flags().StringP("slug", "s", "", "") + MarkOptionalValue(leaf, "y", "phpmyadmin", "xdebug") + root.AddCommand(leaf) + + raw := &cobra.Command{Use: "wp", DisableFlagParsing: true, Run: func(*cobra.Command, []string) {}} + root.AddCommand(raw) + + return root +} + +func TestNormalizeOptionalValues(t *testing.T) { + cases := []struct { + name string + in []string + want []string + }{ + { + // commander: "historical behaviour is optional value is following + // arg unless an option" (Command.parseOptions). + "short flag takes the following token", + []string{"create", "-p", "n"}, + []string{"create", "-p=n"}, + }, + { + "long flag takes the following token", + []string{"create", "--phpmyadmin", "n"}, + []string{"create", "--phpmyadmin=n"}, + }, + { + // _combineFlagAndOptionalValue defaults to true in commander. + "attached short value", + []string{"create", "-pn"}, + []string{"create", "-p=n"}, + }, + { + "bare flag at end of argv keeps its NoOptDefVal", + []string{"create", "--phpmyadmin"}, + []string{"create", "--phpmyadmin"}, + }, + { + "following option token is not consumed as a value", + []string{"create", "--phpmyadmin", "--xdebug", "n"}, + []string{"create", "--phpmyadmin", "--xdebug=n"}, + }, + { + "inline value is left alone", + []string{"create", "--phpmyadmin=n"}, + []string{"create", "--phpmyadmin=n"}, + }, + { + // Node isOptionToken(): `arg !== '-' && arg.startsWith('-')`, so a + // bare dash IS a value. + "bare dash is a value, not an option", + []string{"create", "--phpmyadmin", "-"}, + []string{"create", "--phpmyadmin=-"}, + }, + { + "ordinary value flags are untouched", + []string{"create", "--slug", "Example", "-p", "n"}, + []string{"create", "--slug", "Example", "-p=n"}, + }, + { + "nothing past the -- terminator is rewritten", + []string{"create", "--", "--phpmyadmin", "n"}, + []string{"create", "--", "--phpmyadmin", "n"}, + }, + { + "DisableFlagParsing commands are passed through verbatim", + []string{"wp", "--phpmyadmin", "n"}, + []string{"wp", "--phpmyadmin", "n"}, + }, + { + "unresolvable command is passed through verbatim", + []string{"nope", "--phpmyadmin", "n"}, + []string{"nope", "--phpmyadmin", "n"}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := NormalizeOptionalValues(testTree(), c.in) + if !slices.Equal(got, c.want) { + t.Errorf("NormalizeOptionalValues(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +// The normalizer is only half the fix; the flag must also carry NoOptDefVal so +// the bare form means "enable" rather than "flag needs an argument". +func TestMarkOptionalValueSetsNoOptDefVal(t *testing.T) { + root := testTree() + leaf, _, err := root.Find([]string{"create"}) + if err != nil { + t.Fatal(err) + } + f := leaf.Flags().Lookup("phpmyadmin") + if f.NoOptDefVal != "y" { + t.Errorf("NoOptDefVal = %q, want \"y\"", f.NoOptDefVal) + } + if leaf.Flags().Lookup("slug").NoOptDefVal != "" { + t.Error("--slug must not become an optional-value flag") + } +} + +// End-to-end through cobra's own parser: this is the assertion that would still +// have passed with the old bool flags if it only exercised ProcessBooleanOption. +func TestOptionalValueParsesThroughCobra(t *testing.T) { + cases := []struct { + argv []string + want bool + }{ + {[]string{"create", "-p", "n"}, false}, + {[]string{"create", "-p", "no"}, false}, + {[]string{"create", "-p", "false"}, false}, + {[]string{"create", "-p", "0"}, false}, + {[]string{"create", "--phpmyadmin", "n"}, false}, + {[]string{"create", "--phpmyadmin=n"}, false}, + {[]string{"create", "-pn"}, false}, + {[]string{"create", "-p"}, true}, + {[]string{"create", "--phpmyadmin"}, true}, + {[]string{"create", "-p", "y"}, true}, + {[]string{"create", "--phpmyadmin=yes"}, true}, + {[]string{"create", "--phpmyadmin", "maybe"}, true}, // Node: not in FALSE_OPTIONS => true + } + for _, c := range cases { + root := testTree() + argv := NormalizeOptionalValues(root, c.argv) + leaf, rest, err := root.Find(argv) + if err != nil { + t.Fatalf("%q: find: %v", c.argv, err) + } + if err := leaf.ParseFlags(rest); err != nil { + t.Fatalf("%q: parse: %v", c.argv, err) + } + raw, _ := leaf.Flags().GetString("phpmyadmin") + if got := ProcessBooleanOption(raw); got != c.want { + t.Errorf("%q => raw %q => %v, want %v", c.argv, raw, got, c.want) + } + } +} diff --git a/internal/output/csv.go b/internal/output/csv.go new file mode 100644 index 000000000..9a57057f3 --- /dev/null +++ b/internal/output/csv.go @@ -0,0 +1,178 @@ +package output + +import ( + json "encoding/json/v2" + "fmt" + "io" + "reflect" + "sort" + "strconv" + "strings" +) + +func renderCSV(w io.Writer, data any) error { + switch v := data.(type) { + case HeaderData: + keys := make([]string, 0, len(v.Header)) + for k := range v.Header { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if _, err := fmt.Fprintf(w, "# %s: %s\n", k, v.Header[k]); err != nil { + return err + } + } + return renderCSVRows(w, v.Data) + default: + return renderCSVRows(w, data) + } +} + +func renderCSVRows(w io.Writer, data any) error { + switch v := data.(type) { + case Rows: + return renderCSVMapRows(w, v) + case OrderedRows: + return renderCSVOrderedRows(w, v) + default: + return fmt.Errorf("CSV renderer requires Rows or OrderedRows, got %T", data) + } +} + +func renderCSVMapRows(w io.Writer, rows Rows) error { + if len(rows) == 0 { + return nil + } + + // Stable column order: sorted union of keys across rows. + colset := map[string]struct{}{} + for _, r := range rows { + for k := range r { + colset[k] = struct{}{} + } + } + cols := make([]string, 0, len(colset)) + for k := range colset { + cols = append(cols, k) + } + sort.Strings(cols) + + if err := writeCSVHeader(w, cols); err != nil { + return err + } + for _, r := range rows { + rec := make([]any, len(cols)) + for i, c := range cols { + if v, ok := r[c]; ok { + rec[i] = v + } + } + if err := writeCSVValues(w, rec); err != nil { + return err + } + } + return nil +} + +func renderCSVOrderedRows(w io.Writer, rows OrderedRows) error { + if len(rows) == 0 { + return nil + } + + cols := rows.Columns() + if err := writeCSVHeader(w, cols); err != nil { + return err + } + for _, r := range rows { + rec := make([]any, len(cols)) + for i, c := range cols { + rec[i] = r.ValueAt(c) + } + if err := writeCSVValues(w, rec); err != nil { + return err + } + } + return nil +} + +func writeCSVHeader(w io.Writer, columns []string) error { + values := make([]string, len(columns)) + for i, column := range columns { + values[i] = quoteCSVString(HumanizeField(column)) + } + return writeCSVLine(w, values) +} + +func writeCSVValues(w io.Writer, values []any) error { + encoded := make([]string, len(values)) + for i, value := range values { + cell, err := encodeCSVValue(value) + if err != nil { + return fmt.Errorf("encode CSV value in column %d: %w", i, err) + } + encoded[i] = cell + } + return writeCSVLine(w, encoded) +} + +func writeCSVLine(w io.Writer, values []string) error { + _, err := io.WriteString(w, strings.Join(values, ",")+"\n") + return err +} + +func encodeCSVValue(value any) (string, error) { + if value == nil { + return "", nil + } + switch v := value.(type) { + case string: + return quoteCSVString(v), nil + case bool: + return strconv.FormatBool(v), nil + case int: + return strconv.FormatInt(int64(v), 10), nil + case int8: + return strconv.FormatInt(int64(v), 10), nil + case int16: + return strconv.FormatInt(int64(v), 10), nil + case int32: + return strconv.FormatInt(int64(v), 10), nil + case int64: + return strconv.FormatInt(v, 10), nil + case uint: + return strconv.FormatUint(uint64(v), 10), nil + case uint8: + return strconv.FormatUint(uint64(v), 10), nil + case uint16: + return strconv.FormatUint(uint64(v), 10), nil + case uint32: + return strconv.FormatUint(uint64(v), 10), nil + case uint64: + return strconv.FormatUint(v, 10), nil + case float32: + return strconv.FormatFloat(float64(v), 'g', -1, 32), nil + case float64: + return strconv.FormatFloat(v, 'g', -1, 64), nil + } + + rv := reflect.ValueOf(value) + if rv.Kind() == reflect.Pointer { + if rv.IsNil() { + return "", nil + } + return encodeCSVValue(rv.Elem().Interface()) + } + if rv.Kind() == reflect.Map || rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array || rv.Kind() == reflect.Struct { + encoded, err := json.Marshal(value) + if err != nil { + return "", err + } + return quoteCSVString(string(encoded)), nil + } + return quoteCSVString(fmt.Sprint(value)), nil +} + +func quoteCSVString(value string) string { + return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` +} diff --git a/internal/output/fields.go b/internal/output/fields.go new file mode 100644 index 000000000..ad13fafe6 --- /dev/null +++ b/internal/output/fields.go @@ -0,0 +1,22 @@ +package output + +import ( + "strings" + "unicode" +) + +// HumanizeField mirrors the transform used by the Node CLI's formatData: +// key.split(/(?=[A-Z])/).join(' ').toLowerCase(). The split is deliberately +// ASCII-only, matching JavaScript's [A-Z] character class. +func HumanizeField(field string) string { + var humanized strings.Builder + first := true + for _, r := range field { + if !first && r >= 'A' && r <= 'Z' { + humanized.WriteByte(' ') + } + humanized.WriteRune(unicode.ToLower(r)) + first = false + } + return humanized.String() +} diff --git a/internal/output/fields_test.go b/internal/output/fields_test.go new file mode 100644 index 000000000..4798c2f10 --- /dev/null +++ b/internal/output/fields_test.go @@ -0,0 +1,21 @@ +package output + +import "testing" + +func TestHumanizeFieldMatchesNodeCamelCaseSplit(t *testing.T) { + tests := map[string]string{ + "appId": "app id", + "appID": "app i d", + "currentCommit": "current commit", + "name": "name", + "Name": "name", + "ID": "i d", + } + for input, want := range tests { + t.Run(input, func(t *testing.T) { + if got := HumanizeField(input); got != want { + t.Fatalf("HumanizeField(%q) = %q, want %q", input, got, want) + } + }) + } +} diff --git a/internal/output/ids.go b/internal/output/ids.go new file mode 100644 index 000000000..8d4c9ca97 --- /dev/null +++ b/internal/output/ids.go @@ -0,0 +1,26 @@ +package output + +import ( + "fmt" + "io" + "strings" +) + +func renderIDs(w io.Writer, data any) error { + rows, ok := data.(OrderedRows) + if !ok { + return fmt.Errorf("ids renderer requires OrderedRows, got %T", data) + } + if len(rows) == 0 { + return nil + } + parts := make([]string, 0, len(rows)) + for _, r := range rows { + if len(r) == 0 { + continue + } + parts = append(parts, fmt.Sprint(r[0].Value)) + } + _, err := fmt.Fprintln(w, strings.Join(parts, " ")) + return err +} diff --git a/internal/output/ids_test.go b/internal/output/ids_test.go new file mode 100644 index 000000000..2c758d960 --- /dev/null +++ b/internal/output/ids_test.go @@ -0,0 +1,32 @@ +package output + +import ( + "bytes" + "testing" +) + +func TestRenderIDs(t *testing.T) { + var buf bytes.Buffer + rows := OrderedRows{ + {{Key: "id", Value: "FOO"}}, + {{Key: "id", Value: "BAR"}}, + {{Key: "id", Value: "BAZ"}}, + } + if err := renderIDs(&buf, rows); err != nil { + t.Fatalf("renderIDs: %v", err) + } + want := "FOO BAR BAZ\n" + if buf.String() != want { + t.Errorf("got %q, want %q", buf.String(), want) + } +} + +func TestRenderIDsEmpty(t *testing.T) { + var buf bytes.Buffer + if err := renderIDs(&buf, OrderedRows{}); err != nil { + t.Fatalf("renderIDs: %v", err) + } + if buf.Len() != 0 { + t.Errorf("empty input must produce empty output; got %q", buf.String()) + } +} diff --git a/internal/output/json.go b/internal/output/json.go new file mode 100644 index 000000000..8ab0329b8 --- /dev/null +++ b/internal/output/json.go @@ -0,0 +1,100 @@ +package output + +import ( + "bytes" + "encoding/json/jsontext" + json "encoding/json/v2" + "fmt" + "io" +) + +// renderJSON writes data as tab-indented JSON via encoding/json/v2. +// Indent is "\t" to match Node's JSON.stringify(data, null, '\t') +// in src/lib/cli/format.ts. A trailing newline is appended to match +// Node's console.log behavior. +// +// HeaderData is rendered as just the data payload (the header is +// dropped) to match Node's command.js, where the keyValue header print +// is gated on `options.format !== 'json'` and then `res = res.data` +// runs unconditionally — so formatData never sees the header in JSON +// mode. +// +// OrderedRows is hand-emitted to preserve column insertion order; +// encoding/json/v2 would alphabetize map keys, matching Node's +// JSON.stringify(arrayOfObjects) insertion-order behavior. +func renderJSON(w io.Writer, data any) error { + switch v := data.(type) { + case HeaderData: + // Node parity: drop header in JSON mode; emit only the data payload. + return renderJSON(w, v.Data) + case OrderedRows: + if err := writeOrderedRowsJSON(w, v); err != nil { + return err + } + default: + opts := []json.Options{ + json.Deterministic(true), + jsontext.WithIndent("\t"), + } + if err := json.MarshalWrite(w, data, opts...); err != nil { + return err + } + } + _, err := io.WriteString(w, "\n") + return err +} + +// writeOrderedRowsJSON hand-emits OrderedRows as a JSON array of +// objects with insertion-ordered keys, matching Node's +// JSON.stringify(arrayOfObjects, null, '\t') output. +// +// We hand-roll because encoding/json/v2 sorts map keys alphabetically, +// and a slice of Cell structs would marshal as +// [[{"Key":..., "Value":...}, ...], ...] — wrong shape entirely. +func writeOrderedRowsJSON(w io.Writer, rows OrderedRows) error { + if len(rows) == 0 { + _, err := io.WriteString(w, "[]") + return err + } + + var buf bytes.Buffer + buf.WriteString("[\n") + for i, row := range rows { + buf.WriteString("\t{") + if len(row) > 0 { + buf.WriteByte('\n') + } + for j, cell := range row { + keyJSON, err := json.Marshal(cell.Key) + if err != nil { + return err + } + valJSON, err := json.Marshal(cell.Value) + if err != nil { + return err + } + buf.WriteString("\t\t") + buf.Write(keyJSON) + buf.WriteString(": ") + buf.Write(valJSON) + if j < len(row)-1 { + buf.WriteByte(',') + } + buf.WriteByte('\n') + } + if len(row) > 0 { + buf.WriteString("\t") + } + buf.WriteByte('}') + if i < len(rows)-1 { + buf.WriteByte(',') + } + buf.WriteByte('\n') + } + buf.WriteByte(']') + + if _, err := w.Write(buf.Bytes()); err != nil { + return fmt.Errorf("write OrderedRows JSON: %w", err) + } + return nil +} diff --git a/internal/output/keyvalue.go b/internal/output/keyvalue.go new file mode 100644 index 000000000..410ace498 --- /dev/null +++ b/internal/output/keyvalue.go @@ -0,0 +1,35 @@ +package output + +import ( + "fmt" + "io" +) + +// renderKeyValue handles two row shapes: +// - single-column: {Key: "MY_VAR", Value: "1"} -> "MY_VAR=1" +// - two-column with literal headers: {key: MY_VAR, value: 1} -> "MY_VAR=1" +// +// The two-column form is how envvar get-all formats output when --format=keyValue. +func renderKeyValue(w io.Writer, data any) error { + rows, ok := data.(OrderedRows) + if !ok { + return fmt.Errorf("keyValue renderer requires OrderedRows, got %T", data) + } + for _, r := range rows { + k, v := pickKeyValuePair(r) + if _, err := fmt.Fprintf(w, "%v=%v\n", k, v); err != nil { + return err + } + } + return nil +} + +func pickKeyValuePair(r OrderedRow) (any, any) { + if len(r) == 2 && r[0].Key == "key" && r[1].Key == "value" { + return r[0].Value, r[1].Value + } + if len(r) >= 1 { + return r[0].Key, r[0].Value + } + return "", "" +} diff --git a/internal/output/keyvalue_block.go b/internal/output/keyvalue_block.go new file mode 100644 index 000000000..e39c12e6e --- /dev/null +++ b/internal/output/keyvalue_block.go @@ -0,0 +1,77 @@ +package output + +import ( + "bytes" + "strings" + + "github.com/fatih/color" +) + +// Tuple is Node's `Tuple` from src/lib/cli/format.ts — a key/value pair fed +// to keyValue(). Distinct from the OrderedRow/Cell shapes used by --format +// rendering: this one is the confirmation info-table payload. +type Tuple struct { + Key string + Value string +} + +// keyValueRule is Node's literal separator line (format.ts:116,130) — 35 '='. +const keyValueRule = "===================================" + +// KeyValue ports keyValue() from src/lib/cli/format.ts. +// +// =================================== +// + App: my-app (id: 42) +// + Environment: develop (id: 7) +// =================================== +// +// Two Node details that are easy to get wrong and are pinned by tests: +// - the OPENING rule is emitted only when there is at least one pair, but +// the CLOSING rule is unconditional, so an empty list is a single rule; +// - a row whose key is "environment" (case-insensitive) has its ENTIRE +// value run through FormatEnvironment, which lowercases it. The confirm +// table's value is "production (id: 1)", not "production", so it never +// takes formatEnvironment's red/uppercase production branch. +// +// The returned string has no trailing newline (Node joins with '\n' and the +// caller console.logs it). +func KeyValue(values []Tuple) string { + lines := make([]string, 0, len(values)+2) + if len(values) > 0 { + lines = append(lines, keyValueRule) + } + for _, v := range values { + formatted := v.Value + if strings.EqualFold(v.Key, "environment") { + formatted = FormatEnvironment(v.Value) + } + lines = append(lines, "+ "+v.Key+": "+formatted) + } + lines = append(lines, keyValueRule) + return strings.Join(lines, "\n") +} + +// FormatEnvironment ports formatEnvironment() from src/lib/cli/format.ts: +// an exact (case-insensitive) "production" renders red + UPPERCASED, +// anything else renders bright-blue + lowercased. NO_COLOR and non-TTY +// stdout are honored by fatih/color, matching chalk. +func FormatEnvironment(environment string) string { + if strings.EqualFold(environment, "production") { + return color.RedString(strings.ToUpper(environment)) + } + return color.HiBlueString(strings.ToLower(environment)) +} + +// TableString renders rows the way Node's formatData(rows, 'table') does and +// returns the result as a string: empty for no rows, and no trailing newline. +// Used for the `Replacements` cell inside a KeyValue info table. +func TableString(rows OrderedRows) string { + if len(rows) == 0 { + return "" + } + var buf bytes.Buffer + if err := renderTable(&buf, rows); err != nil { + return "" + } + return strings.TrimRight(buf.String(), "\n") +} diff --git a/internal/output/keyvalue_block_test.go b/internal/output/keyvalue_block_test.go new file mode 100644 index 000000000..89fb31e72 --- /dev/null +++ b/internal/output/keyvalue_block_test.go @@ -0,0 +1,95 @@ +package output + +import ( + "regexp" + "testing" +) + +var ansiRe = regexp.MustCompile("\x1b\\[[0-9;]*m") + +func stripANSI(s string) string { return ansiRe.ReplaceAllString(s, "") } + +// The expected strings below were captured from the shipping Node CLI: +// +// node -e "const {keyValue}=require('./dist/lib/cli/format.js'); +// console.log(JSON.stringify(keyValue([...])))" +// +// KeyValue is the port of src/lib/cli/format.ts keyValue(). It is what +// src/lib/cli/prompt.ts confirm() console.logs above every requireConfirm +// yes/no prompt. + +func TestKeyValueBlockMatchesNode(t *testing.T) { + got := KeyValue([]Tuple{ + {Key: "App", Value: "my-app (id: 42)"}, + {Key: "Environment", Value: "develop (id: 7)"}, + }) + want := "===================================\n" + + "+ App: my-app (id: 42)\n" + + "+ Environment: develop (id: 7)\n" + + "===================================" + if got != want { + t.Errorf("KeyValue mismatch\n got: %q\nwant: %q", got, want) + } +} + +// Node pushes the opening rule only when there is at least one pair, but +// always pushes the closing rule — so an empty list renders as a single +// 35-character rule (format.ts:112-132). +func TestKeyValueBlockEmptyIsSingleRule(t *testing.T) { + got := KeyValue(nil) + want := "===================================" + if got != want { + t.Errorf("KeyValue(nil) = %q, want %q", got, want) + } +} + +// keyValue() special-cases the literal key "environment" (case-insensitive) +// and runs the WHOLE value through formatEnvironment, which lowercases it. +// "Develop (id: 7)" therefore renders as "develop (id: 7)". +func TestKeyValueBlockLowercasesEnvironmentValue(t *testing.T) { + t.Setenv("NO_COLOR", "1") + got := KeyValue([]Tuple{{Key: "Environment", Value: "Develop (id: 7)"}}) + want := "===================================\n" + + "+ Environment: develop (id: 7)\n" + + "===================================" + if got != want { + t.Errorf("KeyValue mismatch\n got: %q\nwant: %q", got, want) + } +} + +// formatEnvironment only reddens+uppercases when the ENTIRE value equals +// "production". The confirm table's Environment value is "production (id: 1)", +// which does not match, so it stays lowercase like any other env. +func TestKeyValueBlockProductionRowIsNotUppercased(t *testing.T) { + t.Setenv("NO_COLOR", "1") + got := KeyValue([]Tuple{{Key: "Environment", Value: "production (id: 1)"}}) + want := "===================================\n" + + "+ Environment: production (id: 1)\n" + + "===================================" + if got != want { + t.Errorf("KeyValue mismatch\n got: %q\nwant: %q", got, want) + } +} + +// Node's table for the sync `Replacements` / import-sql `Replacements` rows +// comes from formatData(rows, 'table'), which returns the empty string for +// an empty slice and otherwise has NO trailing newline. +func TestNodeTableStringHasNoTrailingNewline(t *testing.T) { + got := TableString(OrderedRows{ + {{Key: "from", Value: "a.com"}, {Key: "to", Value: "b.com"}}, + }) + want := "┌───────┬───────┐\n" + + "│ from │ to │\n" + + "├───────┼───────┤\n" + + "│ a.com │ b.com │\n" + + "└───────┴───────┘" + if stripANSI(got) != want { + t.Errorf("TableString mismatch\n got: %q\nwant: %q", stripANSI(got), want) + } +} + +func TestNodeTableStringEmptyIsEmpty(t *testing.T) { + if got := TableString(OrderedRows{}); got != "" { + t.Errorf("TableString(empty) = %q, want \"\"", got) + } +} diff --git a/internal/output/keyvalue_test.go b/internal/output/keyvalue_test.go new file mode 100644 index 000000000..a8798a872 --- /dev/null +++ b/internal/output/keyvalue_test.go @@ -0,0 +1,35 @@ +package output + +import ( + "bytes" + "testing" +) + +func TestRenderKeyValue(t *testing.T) { + var buf bytes.Buffer + rows := OrderedRows{ + {{Key: "MY_VAR", Value: "1"}}, + {{Key: "OTHER_VAR", Value: "two"}}, + } + if err := renderKeyValue(&buf, rows); err != nil { + t.Fatalf("renderKeyValue: %v", err) + } + want := "MY_VAR=1\nOTHER_VAR=two\n" + if buf.String() != want { + t.Errorf("got %q, want %q", buf.String(), want) + } +} + +func TestRenderKeyValueTwoColumns(t *testing.T) { + var buf bytes.Buffer + rows := OrderedRows{ + {{Key: "key", Value: "MY_VAR"}, {Key: "value", Value: "1"}}, + } + if err := renderKeyValue(&buf, rows); err != nil { + t.Fatalf("renderKeyValue: %v", err) + } + want := "MY_VAR=1\n" + if buf.String() != want { + t.Errorf("got %q, want %q", buf.String(), want) + } +} diff --git a/internal/output/ordered.go b/internal/output/ordered.go new file mode 100644 index 000000000..6138184a3 --- /dev/null +++ b/internal/output/ordered.go @@ -0,0 +1,44 @@ +// internal/output/ordered.go +package output + +// Cell is one column of an OrderedRow. +type Cell struct { + Key string + Value any +} + +// OrderedRow is a column-ordered alternative to map[string]any. Used by +// commands whose JSON / CSV / text output must match Node's insertion order +// (Go's map iteration is randomized). +type OrderedRow []Cell + +// Keys returns the keys in insertion order. +func (r OrderedRow) Keys() []string { + out := make([]string, len(r)) + for i, c := range r { + out[i] = c.Key + } + return out +} + +// ValueAt returns the value for key, or nil if absent. +func (r OrderedRow) ValueAt(key string) any { + for _, c := range r { + if c.Key == key { + return c.Value + } + } + return nil +} + +// OrderedRows is a slice of OrderedRow. +type OrderedRows []OrderedRow + +// Columns returns the column key order taken from the first row. +// Empty OrderedRows returns nil. +func (rs OrderedRows) Columns() []string { + if len(rs) == 0 { + return nil + } + return rs[0].Keys() +} diff --git a/internal/output/ordered_test.go b/internal/output/ordered_test.go new file mode 100644 index 000000000..4553823e7 --- /dev/null +++ b/internal/output/ordered_test.go @@ -0,0 +1,42 @@ +// internal/output/ordered_test.go +package output + +import ( + "reflect" + "testing" +) + +func TestOrderedRowKeysInOrder(t *testing.T) { + r := OrderedRow{ + {Key: "id", Value: 42}, + {Key: "name", Value: "x"}, + {Key: "repo", Value: "wpcomvip/x"}, + } + got := r.Keys() + want := []string{"id", "name", "repo"} + if !reflect.DeepEqual(got, want) { + t.Errorf("Keys() = %v, want %v", got, want) + } +} + +func TestOrderedRowValueAt(t *testing.T) { + r := OrderedRow{{Key: "k", Value: "v"}} + if r.ValueAt("k") != "v" { + t.Errorf("ValueAt(k) = %v, want v", r.ValueAt("k")) + } + if r.ValueAt("missing") != nil { + t.Errorf("ValueAt(missing) = %v, want nil", r.ValueAt("missing")) + } +} + +func TestOrderedRowsAllKeysFromFirst(t *testing.T) { + rs := OrderedRows{ + {{Key: "a", Value: 1}, {Key: "b", Value: 2}}, + {{Key: "a", Value: 3}, {Key: "b", Value: 4}}, + } + got := rs.Columns() + want := []string{"a", "b"} + if !reflect.DeepEqual(got, want) { + t.Errorf("Columns() = %v, want %v", got, want) + } +} diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 000000000..717a15081 --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,62 @@ +// Package output renders command results in table, CSV, or JSON. +// +// Handlers return one of: +// - HeaderData{Header, Data} — printed as a key:value block followed by formatted data +// - Rows — printed as table/csv/json +// - nil — no output +// +// The format is selected by --format on commands that opt in via the +// WithFormat middleware. See spec §6.1. +package output + +import ( + "fmt" + "io" +) + +// Format is the output format requested by --format. +type Format string + +const ( + FormatTable Format = "table" + FormatCSV Format = "csv" + FormatJSON Format = "json" + FormatText Format = "text" + FormatKeyValue Format = "keyValue" + FormatIDs Format = "ids" +) + +// Rows is a slice of string-keyed maps, the primary tabular return type from +// command handlers. +type Rows []map[string]any + +// HeaderData wraps a key/value header section and a data payload. The header +// is rendered above the data (table/csv) or as "__header" (json). +type HeaderData struct { + Header map[string]string + Data any +} + +// Render dispatches data to the appropriate renderer for format f. +// If data is nil, Render returns immediately without writing anything. +func Render(w io.Writer, f Format, data any) error { + if data == nil { + return nil + } + switch f { + case FormatJSON: + return renderJSON(w, data) + case FormatCSV: + return renderCSV(w, data) + case FormatText: + return renderText(w, data) + case FormatKeyValue: + return renderKeyValue(w, data) + case FormatIDs: + return renderIDs(w, data) + case FormatTable, "": + return renderTable(w, data) + default: + return fmt.Errorf("unknown output format %q (want table, csv, json, text, keyValue, or ids)", f) + } +} diff --git a/internal/output/output_test.go b/internal/output/output_test.go new file mode 100644 index 000000000..bfe6d7a23 --- /dev/null +++ b/internal/output/output_test.go @@ -0,0 +1,382 @@ +package output + +import ( + "bytes" + "strings" + "testing" +) + +func TestRenderJSONRows(t *testing.T) { + data := Rows{ + {"id": 1, "name": "alpha"}, + {"id": 2, "name": "beta"}, + } + var buf bytes.Buffer + if err := Render(&buf, FormatJSON, data); err != nil { + t.Fatalf("Render: %v", err) + } + got := buf.String() + if !strings.Contains(got, `"name": "alpha"`) || !strings.Contains(got, `"name": "beta"`) { + t.Errorf("missing expected entries in JSON output: %q", got) + } +} + +func TestRenderJSONHeaderData(t *testing.T) { + // Node parity: in JSON mode, command.js drops res.header entirely and + // only emits res.data. See src/lib/cli/command.js — the keyValue header + // print is gated on `options.format !== 'json'`, then `res = res.data` + // runs unconditionally. So formatData never sees the header in JSON mode. + data := HeaderData{ + Header: map[string]string{"app": "my-site"}, + Data: Rows{{"id": 1}}, + } + var buf bytes.Buffer + if err := Render(&buf, FormatJSON, data); err != nil { + t.Fatalf("Render: %v", err) + } + got := buf.String() + if strings.Contains(got, "__header") || strings.Contains(got, `"header"`) || + strings.Contains(got, "my-site") { + t.Errorf("JSON HeaderData must drop header for Node parity; got:\n%s", got) + } + if !strings.Contains(got, `"id": 1`) { + t.Errorf("JSON HeaderData must emit data payload; got:\n%s", got) + } +} + +func TestRenderJSONNilNoOutput(t *testing.T) { + var buf bytes.Buffer + if err := Render(&buf, FormatJSON, nil); err != nil { + t.Fatalf("Render: %v", err) + } + if buf.Len() != 0 { + t.Errorf("nil data must produce no output, got %q", buf.String()) + } +} + +func TestRenderRejectsUnknownFormat(t *testing.T) { + var buf bytes.Buffer + err := Render(&buf, Format("xml"), Rows{{"id": 1}}) + if err == nil { + t.Fatal("expected error for unknown format") + } +} + +func TestRenderJSONHasTrailingNewline(t *testing.T) { + var buf bytes.Buffer + if err := Render(&buf, FormatJSON, Rows{{"id": 1}}); err != nil { + t.Fatalf("Render: %v", err) + } + got := buf.String() + if len(got) == 0 || got[len(got)-1] != '\n' { + t.Errorf("JSON output must end with newline; got: %q", got) + } +} + +func TestRenderCSVRows(t *testing.T) { + data := Rows{ + {"id": 1, "name": "alpha"}, + {"id": 2, "name": "beta"}, + } + var buf bytes.Buffer + if err := Render(&buf, FormatCSV, data); err != nil { + t.Fatalf("Render: %v", err) + } + got := buf.String() + wantHeader := `"id","name"` + if !strings.HasPrefix(got, wantHeader) { + t.Errorf("CSV must start with sorted header %q, got %q", wantHeader, got) + } + if !strings.Contains(got, `1,"alpha"`) || !strings.Contains(got, `2,"beta"`) { + t.Errorf("CSV output missing rows: %q", got) + } +} + +func TestRenderCSVMatchesNodeTypedQuoting(t *testing.T) { + var buf bytes.Buffer + rows := OrderedRows{ + { + {Key: "appId", Value: 7}, + {Key: "name", Value: "alpha"}, + {Key: "active", Value: true}, + {Key: "empty", Value: nil}, + }, + } + if err := Render(&buf, FormatCSV, rows); err != nil { + t.Fatalf("Render: %v", err) + } + want := "\"app id\",\"name\",\"active\",\"empty\"\n7,\"alpha\",true,\n" + if got := buf.String(); got != want { + t.Fatalf("csv = %q, want %q", got, want) + } +} + +func TestRenderCSVEscapesQuotesLikeJSON2CSV(t *testing.T) { + var buf bytes.Buffer + rows := OrderedRows{ + {{Key: "value", Value: "a\"b"}}, + } + if err := Render(&buf, FormatCSV, rows); err != nil { + t.Fatalf("Render: %v", err) + } + want := "\"value\"\n\"a\"\"b\"\n" + if got := buf.String(); got != want { + t.Fatalf("csv = %q, want %q", got, want) + } +} + +func TestRenderCSVHeaderDataPrintsHeaderLines(t *testing.T) { + data := HeaderData{ + Header: map[string]string{"app": "my-site", "env": "staging"}, + Data: Rows{{"id": 1, "name": "alpha"}}, + } + var buf bytes.Buffer + if err := Render(&buf, FormatCSV, data); err != nil { + t.Fatalf("Render: %v", err) + } + got := buf.String() + if !strings.Contains(got, "# app: my-site") { + t.Errorf("HeaderData CSV missing header comment for app: %q", got) + } + if !strings.Contains(got, "# env: staging") { + t.Errorf("HeaderData CSV missing header comment for env: %q", got) + } +} + +func TestRenderTableRows(t *testing.T) { + data := Rows{ + {"id": 1, "name": "alpha"}, + {"id": 2, "name": "beta"}, + } + var buf bytes.Buffer + if err := Render(&buf, FormatTable, data); err != nil { + t.Fatalf("Render: %v", err) + } + got := buf.String() + for _, want := range []string{"id", "name", "alpha", "beta"} { + if !strings.Contains(got, want) { + t.Errorf("table output missing %q:\n%s", want, got) + } + } +} + +// A bytes.Buffer is not a terminal, so this pins the shape a redirect, a pipe, +// a cron job or `docker exec` sees. Node clears cli-table3's head and border +// styles in exactly that situation (src/bin/vip-logs.js:171-172, and via the +// colour layer's own TTY detection for src/lib/cli/format.ts `table()`), so +// there are no escape bytes anywhere in the frame. +func TestRenderTableOrderedRowsNonTTYMatchesNodeCLI(t *testing.T) { + rows := OrderedRows{ + {{Key: "id", Value: 1}, {Key: "appId", Value: 1}, {Key: "name", Value: "alpha"}}, + {{Key: "id", Value: 20}, {Key: "appId", Value: 20}, {Key: "name", Value: "beta"}}, + } + var buf bytes.Buffer + if err := Render(&buf, FormatTable, rows); err != nil { + t.Fatal(err) + } + want := "┌────┬────────┬───────┐\n" + + "│ id │ app id │ name │\n" + + "├────┼────────┼───────┤\n" + + "│ 1 │ 1 │ alpha │\n" + + "├────┼────────┼───────┤\n" + + "│ 20 │ 20 │ beta │\n" + + "└────┴────────┴───────┘\n" + if got := buf.String(); got != want { + t.Fatalf("table diff\nwant: %q\n got: %q", want, got) + } + if strings.Contains(buf.String(), "\x1b[") { + t.Errorf("non-TTY table carries ANSI:\n%q", buf.String()) + } +} + +// The terminal shape is unchanged: grey borders, bright-blue head. +func TestRenderTableOrderedRowsTTYMatchesNodeCLI(t *testing.T) { + headers := []string{"id", "app id", "name"} + rows := [][]string{{"1", "1", "alpha"}, {"20", "20", "beta"}} + + var buf bytes.Buffer + if err := renderNodeTableStyled(&buf, headers, rows, 0, true); err != nil { + t.Fatal(err) + } + want := "\x1b[90m┌────\x1b[39m\x1b[90m┬────────\x1b[39m\x1b[90m┬───────┐\x1b[39m\n" + + "\x1b[90m│\x1b[39m\x1b[94m id \x1b[39m\x1b[90m│\x1b[39m\x1b[94m app id \x1b[39m\x1b[90m│\x1b[39m\x1b[94m name \x1b[39m\x1b[90m│\x1b[39m\n" + + "\x1b[90m├────\x1b[39m\x1b[90m┼────────\x1b[39m\x1b[90m┼───────┤\x1b[39m\n" + + "\x1b[90m│\x1b[39m 1 \x1b[90m│\x1b[39m 1 \x1b[90m│\x1b[39m alpha \x1b[90m│\x1b[39m\n" + + "\x1b[90m├────\x1b[39m\x1b[90m┼────────\x1b[39m\x1b[90m┼───────┤\x1b[39m\n" + + "\x1b[90m│\x1b[39m 20 \x1b[90m│\x1b[39m 20 \x1b[90m│\x1b[39m beta \x1b[90m│\x1b[39m\n" + + "\x1b[90m└────\x1b[39m\x1b[90m┴────────\x1b[39m\x1b[90m┴───────┘\x1b[39m\n" + if got := buf.String(); got != want { + t.Fatalf("table diff\nwant: %q\n got: %q", want, got) + } +} + +// Clearing the head/border styles must not touch ANSI that came in with the +// DATA — Node clears styles, it does not strip cells. +func TestRenderTableMultilineAndANSIMatchesNodeCLI(t *testing.T) { + rows := OrderedRows{ + {{Key: "id", Value: 1}, {Key: "value", Value: "a\nb"}}, + {{Key: "id", Value: 2}, {Key: "value", Value: "\x1b[31mred\x1b[39m"}}, + } + var buf bytes.Buffer + if err := Render(&buf, FormatTable, rows); err != nil { + t.Fatal(err) + } + want := "┌────┬───────┐\n" + + "│ id │ value │\n" + + "├────┼───────┤\n" + + "│ 1 │ a │\n" + + "│ │ b │\n" + + "├────┼───────┤\n" + + "│ 2 │ \x1b[31mred\x1b[39m │\n" + + "└────┴───────┘\n" + if got := buf.String(); got != want { + t.Fatalf("table diff\nwant: %q\n got: %q", want, got) + } +} + +func TestRenderTableAtWidthWrapsLongLogMessageWithoutWrappingBorders(t *testing.T) { + headers := []string{"timestamp", "message"} + message := "PHP message: [ERROR] Permission denied for MCP API access. User ID 0 does not have capability read." + rows := [][]string{{"2026-07-15T07:17:38.002797318Z", message}} + + var buf bytes.Buffer + if err := renderNodeTableAtWidth(&buf, headers, rows, 78); err != nil { + t.Fatal(err) + } + for lineNumber, line := range strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n") { + if width := nodeDisplayWidth(line); width > 78 { + t.Fatalf("line %d width = %d, want <= 78: %q", lineNumber+1, width, line) + } + } + for _, word := range strings.Fields(message) { + if !strings.Contains(stripNodeANSI(buf.String()), word) { + t.Fatalf("rendered table lost message word %q:\n%s", word, buf.String()) + } + } +} + +func TestRenderNodeTableNonTTYKeepsNaturalWidth(t *testing.T) { + headers := []string{"timestamp", "message"} + rows := [][]string{{"2026-07-15T07:17:38.002797318Z", strings.Repeat("wide ", 30)}} + + var buf bytes.Buffer + if err := renderNodeTable(&buf, headers, rows); err != nil { + t.Fatal(err) + } + if width := nodeDisplayWidth(strings.Split(buf.String(), "\n")[0]); width <= 78 { + t.Fatalf("non-TTY natural table width = %d, want > 78", width) + } +} + +func TestRenderNodeTableNonTTYDoesNotRewriteANSIStateAcrossExplicitLines(t *testing.T) { + headers := []string{"value"} + rows := [][]string{{"\x1b[31malpha\nbeta\x1b[39m"}} + + var buf bytes.Buffer + if err := renderNodeTable(&buf, headers, rows); err != nil { + t.Fatal(err) + } + if got := strings.Count(buf.String(), "\x1b[31m"); got != 1 { + t.Fatalf("non-TTY renderer wrote red foreground %d times, want original byte sequence once: %q", got, buf.String()) + } +} + +func TestNodeDisplayWidthMatchesNodeStripANSICompatibility(t *testing.T) { + tests := map[string]struct { + value string + want int + }{ + "SGR color": { + value: "\x1b[31mred\x1b[39m", + want: 3, + }, + "OSC hyperlink with ansi-regex v5 behavior": { + value: "\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\", + want: 26, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + if got := nodeDisplayWidth(test.value); got != test.want { + t.Fatalf("nodeDisplayWidth() = %d, want %d", got, test.want) + } + }) + } +} + +func TestRenderTableHeaderDataPrintsHeaderBlock(t *testing.T) { + data := HeaderData{ + Header: map[string]string{"app": "my-site"}, + Data: Rows{{"id": 1}}, + } + var buf bytes.Buffer + if err := Render(&buf, FormatTable, data); err != nil { + t.Fatalf("Render: %v", err) + } + got := buf.String() + if !strings.Contains(got, "app: my-site") { + t.Errorf("header block missing: %q", got) + } + if !strings.Contains(got, "id") { + t.Errorf("table missing after header block: %q", got) + } +} + +func TestRenderTableHeaderDataWithOrderedRows(t *testing.T) { + var buf bytes.Buffer + hd := HeaderData{ + Header: map[string]string{"id": "42", "name": "myapp"}, + Data: OrderedRows{ + {{Key: "envid", Value: 7}, {Key: "envname", Value: "develop"}}, + }, + } + if err := Render(&buf, FormatTable, hd); err != nil { + t.Fatalf("Render: %v", err) + } + got := buf.String() + for _, want := range []string{"id: 42", "name: myapp", "develop"} { + if !strings.Contains(got, want) { + t.Errorf("table HeaderData output missing %q in:\n%s", want, got) + } + } +} + +func TestRenderJSONOrderedRowsPreservesKeyOrder(t *testing.T) { + // Node parity: JSON.stringify of an array of objects emits objects with + // keys in insertion order, e.g. [{"zeta":1,"alpha":2}]. The Cell struct + // shape ({"Key":..., "Value":...}) must NOT leak into output. + var buf bytes.Buffer + rows := OrderedRows{ + {{Key: "zeta", Value: 1}, {Key: "alpha", Value: 2}}, + } + if err := Render(&buf, FormatJSON, rows); err != nil { + t.Fatalf("Render: %v", err) + } + got := buf.String() + if strings.Contains(got, `"Key"`) || strings.Contains(got, `"Value"`) { + t.Fatalf("Cell struct fields must not leak into JSON; got:\n%s", got) + } + zetaIdx := strings.Index(got, `"zeta"`) + alphaIdx := strings.Index(got, `"alpha"`) + if zetaIdx < 0 || alphaIdx < 0 { + t.Fatalf("missing keys in output: %q", got) + } + if zetaIdx > alphaIdx { + t.Errorf("OrderedRows must preserve insertion order; got:\n%s", got) + } +} + +func TestRenderCSVOrderedRows(t *testing.T) { + var buf bytes.Buffer + rows := OrderedRows{ + {{Key: "id", Value: 1}, {Key: "name", Value: "a"}}, + {{Key: "id", Value: 2}, {Key: "name", Value: "b"}}, + } + if err := Render(&buf, FormatCSV, rows); err != nil { + t.Fatalf("Render: %v", err) + } + want := "\"id\",\"name\"\n1,\"a\"\n2,\"b\"\n" + if buf.String() != want { + t.Errorf("csv OrderedRows = %q, want %q", buf.String(), want) + } +} diff --git a/internal/output/table.go b/internal/output/table.go new file mode 100644 index 000000000..31459c109 --- /dev/null +++ b/internal/output/table.go @@ -0,0 +1,231 @@ +package output + +import ( + "fmt" + "io" + "sort" + "strings" +) + +const ( + ansiGray = "\x1b[90m" + ansiBrightBlue = "\x1b[94m" + ansiFgClose = "\x1b[39m" +) + +func renderTable(w io.Writer, data any) error { + switch v := data.(type) { + case HeaderData: + keys := make([]string, 0, len(v.Header)) + for k := range v.Header { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if _, err := fmt.Fprintf(w, "%s: %s\n", k, v.Header[k]); err != nil { + return err + } + } + if _, err := io.WriteString(w, "\n"); err != nil { + return err + } + return renderTableRows(w, v.Data) + default: + return renderTableRows(w, data) + } +} + +func renderTableRows(w io.Writer, data any) error { + switch v := data.(type) { + case Rows: + return renderTableMapRows(w, v) + case OrderedRows: + return renderTableOrderedRows(w, v) + default: + return fmt.Errorf("table renderer requires Rows or OrderedRows, got %T", data) + } +} + +func renderTableMapRows(w io.Writer, rows Rows) error { + if len(rows) == 0 { + return nil + } + + colset := map[string]struct{}{} + for _, r := range rows { + for k := range r { + colset[k] = struct{}{} + } + } + cols := make([]string, 0, len(colset)) + for k := range colset { + cols = append(cols, k) + } + sort.Strings(cols) + + headers := make([]string, len(cols)) + for i, c := range cols { + headers[i] = HumanizeField(c) + } + values := make([][]string, len(rows)) + for rowIndex, r := range rows { + values[rowIndex] = make([]string, len(cols)) + for columnIndex, c := range cols { + if v, ok := r[c]; ok { + values[rowIndex][columnIndex] = nodeCell(v) + } + } + } + return renderNodeTable(w, headers, values) +} + +func renderTableOrderedRows(w io.Writer, rows OrderedRows) error { + if len(rows) == 0 { + return nil + } + + cols := rows.Columns() + headers := make([]string, len(cols)) + for i, c := range cols { + headers[i] = HumanizeField(c) + } + values := make([][]string, len(rows)) + for rowIndex, r := range rows { + values[rowIndex] = make([]string, len(cols)) + for i, c := range cols { + values[rowIndex][i] = nodeCell(r.ValueAt(c)) + } + } + return renderNodeTable(w, headers, values) +} + +func nodeCell(value any) string { + if value == nil { + return "" + } + return fmt.Sprint(value) +} + +func renderNodeTable(w io.Writer, headers []string, rows [][]string) error { + return renderNodeTableStyled(w, headers, rows, terminalTableWidth(w), terminalTableIsTTY(w)) +} + +// renderNodeTableAtWidth renders at an explicit width with colour ON. It is +// the shape a real terminal gets, and exists so tests can pin that shape +// without a pty. +func renderNodeTableAtWidth(w io.Writer, headers []string, rows [][]string, maxTableWidth int) error { + return renderNodeTableStyled(w, headers, rows, maxTableWidth, true) +} + +// renderNodeTableStyled is the single renderer. `colorize` corresponds to +// cli-table3's style.head/style.border being populated, which the Node CLI +// only does when stdout is a TTY — see terminalTableIsTTY. +// +// Note the cells themselves are NOT stripped: a value that already carries +// ANSI (a coloured environment name, say) keeps it, exactly as it would in +// Node, where only the head/border STYLES are cleared. +func renderNodeTableStyled(w io.Writer, headers []string, rows [][]string, maxTableWidth int, colorize bool) error { + if len(headers) == 0 { + return nil + } + wrapCells := maxTableWidth > 0 && nodeTableWidth(nodeNaturalColumnWidths(headers, rows)) > maxTableWidth + widths := nodeColumnWidths(headers, rows, maxTableWidth) + if err := writeNodeBorder(w, "┌", "┬", "┐", widths, colorize); err != nil { + return err + } + if err := writeNodeRow(w, headers, widths, true, wrapCells, colorize); err != nil { + return err + } + if err := writeNodeBorder(w, "├", "┼", "┤", widths, colorize); err != nil { + return err + } + for i, row := range rows { + if err := writeNodeRow(w, row, widths, false, wrapCells, colorize); err != nil { + return err + } + if i < len(rows)-1 { + if err := writeNodeBorder(w, "├", "┼", "┤", widths, colorize); err != nil { + return err + } + } + } + return writeNodeBorder(w, "└", "┴", "┘", widths, colorize) +} + +func writeNodeBorder(w io.Writer, left, middle, right string, widths []int, colorize bool) error { + for i, width := range widths { + start := middle + if i == 0 { + start = left + } + end := "" + if i == len(widths)-1 { + end = right + } + segment := start + strings.Repeat("─", width+2) + end + if _, err := io.WriteString(w, colorText(ansiGray, segment, colorize)); err != nil { + return err + } + } + _, err := io.WriteString(w, "\n") + return err +} + +func writeNodeRow(w io.Writer, values []string, widths []int, header, wrapCells, colorize bool) error { + lines := make([][]string, len(values)) + height := 1 + for i, value := range values { + if wrapCells { + lines[i] = wrapNodeCell(value, widths[i]) + } else { + lines[i] = strings.Split(value, "\n") + } + if len(lines[i]) > height { + height = len(lines[i]) + } + } + for lineIndex := 0; lineIndex < height; lineIndex++ { + physical := make([]string, len(values)) + for columnIndex := range values { + if lineIndex < len(lines[columnIndex]) { + physical[columnIndex] = lines[columnIndex][lineIndex] + } + } + if err := writeNodePhysicalRow(w, physical, widths, header, colorize); err != nil { + return err + } + } + return nil +} + +func writeNodePhysicalRow(w io.Writer, values []string, widths []int, header, colorize bool) error { + for i, value := range values { + if _, err := io.WriteString(w, colorText(ansiGray, "│", colorize)); err != nil { + return err + } + cell := padNodeCell(value, widths[i]) + if header { + cell = colorText(ansiBrightBlue, cell, colorize) + } + if _, err := io.WriteString(w, cell); err != nil { + return err + } + } + _, err := io.WriteString(w, colorText(ansiGray, "│", colorize)+"\n") + return err +} + +func padNodeCell(value string, width int) string { + return " " + value + strings.Repeat(" ", width-nodeDisplayWidth(value)+1) +} + +// colorText wraps value in an SGR pair, or returns it untouched when the +// destination is not a terminal. Returning the bare string (rather than an +// empty escape pair) matters: the differential compares byte-for-byte against +// Node, whose cleared style produces no escape bytes at all. +func colorText(open, value string, colorize bool) string { + if !colorize { + return value + } + return open + value + ansiFgClose +} diff --git a/internal/output/table_layout.go b/internal/output/table_layout.go new file mode 100644 index 000000000..ad7bc4505 --- /dev/null +++ b/internal/output/table_layout.go @@ -0,0 +1,472 @@ +package output + +import ( + "io" + "regexp" + "strconv" + "strings" + "unicode" + + "github.com/mattn/go-runewidth" + "golang.org/x/term" +) + +const terminalTableSafetyMargin = 2 + +// nodeANSIRegexp is the Go equivalent of ansi-regex v5.0.1, which is what +// cli-table3 reaches through string-width in the Node CLI. Keeping that exact +// compatibility includes its historical OSC handling quirks. +var nodeANSIRegexp = regexp.MustCompile( + `[\x1B\x{009B}][\[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d\\/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d\\/#&.:=?%@~_]*)*)?\x07)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))`, +) + +var nodeSGRRegexp = regexp.MustCompile(`\x1b\[([0-9;]*)m`) + +var nodeStyleCodes = []int{1, 2, 3, 4, 5, 7, 8, 9} + +var nodeStyleCloseCodes = map[int]string{ + 1: "\x1b[22m", + 2: "\x1b[22m", + 3: "\x1b[23m", + 4: "\x1b[24m", + 5: "\x1b[25m", + 7: "\x1b[27m", + 8: "\x1b[28m", + 9: "\x1b[29m", +} + +type nodeWrapToken struct { + raw string + width int + whitespace bool +} + +type nodeSGRState struct { + foreground string + background string + styles map[int]string +} + +type nodeFDWriter interface { + Fd() uintptr +} + +// terminalTableIsTTY reports whether w is an interactive terminal. +// +// It is the Go equivalent of Node's `process.stdout.isTTY`, and it gates BOTH +// of the render-time decisions cli-table3 makes for the Node CLI: +// +// - column widths (see terminalTableWidth below), and +// - whether the table is colourised at all. +// +// The colour half was missing, and that was a real regression: `vip logs` +// under cron, systemd, ssh without a tty, `docker exec`, or any `>file` +// redirect got escape sequences Node would not have written. Node's +// src/bin/vip-logs.js:162-172 says so explicitly — +// +// if ( process.stdout.isTTY && process.stdout.columns ) { +// options.colWidths = [ ... ]; +// } else { +// options.style.head = []; +// options.style.border = []; +// } +// +// and every other table goes through src/lib/cli/format.ts `table()`, which +// asks for `style.head = [ 'brightBlue' ]` and lets cli-table3's colour layer +// decide: that layer disables itself when stdout is not a TTY, so those tables +// come out plain too. One predicate therefore covers both surfaces. +func terminalTableIsTTY(w io.Writer) bool { + f, ok := w.(nodeFDWriter) + if !ok { + return false + } + return term.IsTerminal(int(f.Fd())) +} + +func terminalTableWidth(w io.Writer) int { + f, ok := w.(nodeFDWriter) + if !ok { + return 0 + } + fd := int(f.Fd()) + if !term.IsTerminal(fd) { + return 0 + } + cols, _, err := term.GetSize(fd) + if err != nil || cols <= terminalTableSafetyMargin { + return 0 + } + return cols - terminalTableSafetyMargin +} + +func nodeColumnWidths(headers []string, rows [][]string, maxTableWidth int) []int { + widths := nodeNaturalColumnWidths(headers, rows) + if maxTableWidth <= 0 || nodeTableWidth(widths) <= maxTableWidth { + return widths + } + for i := range widths { + if widths[i] < 1 { + widths[i] = 1 + } + } + + budget := maxTableWidth - nodeTableOverhead(len(widths)) + if budget < len(widths) { + budget = len(widths) + } + + preferred := make([]int, len(headers)) + for i, header := range headers { + preferred[i] = nodeHeaderMinimumWidth(header) + if preferred[i] > widths[i] { + preferred[i] = widths[i] + } + } + shrinkNodeWidths(widths, preferred, budget) + + ones := make([]int, len(widths)) + for i := range ones { + ones[i] = 1 + } + shrinkNodeWidths(widths, ones, budget) + return widths +} + +func nodeNaturalColumnWidths(headers []string, rows [][]string) []int { + widths := make([]int, len(headers)) + for i, header := range headers { + widths[i] = nodeDisplayWidth(header) + } + for _, row := range rows { + for i, value := range row { + if i >= len(widths) { + break + } + for _, line := range strings.Split(value, "\n") { + if width := nodeDisplayWidth(line); width > widths[i] { + widths[i] = width + } + } + } + } + return widths +} + +func nodeHeaderMinimumWidth(header string) int { + minimum := 1 + for _, word := range strings.Fields(stripNodeANSI(header)) { + if width := runewidth.StringWidth(word); width > minimum { + minimum = width + } + } + return minimum +} + +func shrinkNodeWidths(widths, minimums []int, budget int) { + total := sumNodeWidths(widths) + for total > budget { + widest := 0 + for i, width := range widths { + if width > minimums[i] && width > widest { + widest = width + } + } + if widest == 0 { + return + } + for i := range widths { + if total <= budget { + return + } + if widths[i] == widest && widths[i] > minimums[i] { + widths[i]-- + total-- + } + } + } +} + +func sumNodeWidths(widths []int) int { + total := 0 + for _, width := range widths { + total += width + } + return total +} + +func nodeTableOverhead(columns int) int { + return 3*columns + 1 +} + +func nodeTableWidth(widths []int) int { + return sumNodeWidths(widths) + nodeTableOverhead(len(widths)) +} + +func nodeDisplayWidth(value string) int { + return runewidth.StringWidth(stripNodeANSI(value)) +} + +func stripNodeANSI(value string) string { + return nodeANSIRegexp.ReplaceAllString(value, "") +} + +func wrapNodeCell(value string, width int) []string { + if width < 1 { + width = 1 + } + + var lines []string + for _, logicalLine := range strings.Split(value, "\n") { + lines = append(lines, wrapNodeLogicalLine(logicalLine, width)...) + } + return colorizeNodeLines(lines) +} + +func wrapNodeLogicalLine(value string, width int) []string { + if value == "" { + return []string{""} + } + + tokens := tokenizeNodeText(value) + lines := make([]string, 0, 1) + for len(tokens) > 0 { + line, rest := splitNodeTokensAtBoundary(tokens, width) + lines = append(lines, nodeTokensString(line)) + tokens = rest + } + if len(lines) == 0 { + return []string{""} + } + return lines +} + +func tokenizeNodeText(value string) []nodeWrapToken { + var tokens []nodeWrapToken + appendVisible := func(text string) { + for _, r := range text { + tokens = append(tokens, nodeWrapToken{ + raw: string(r), + width: runewidth.RuneWidth(r), + whitespace: unicode.IsSpace(r), + }) + } + } + + position := 0 + for _, location := range nodeANSIRegexp.FindAllStringIndex(value, -1) { + appendVisible(value[position:location[0]]) + tokens = append(tokens, nodeWrapToken{raw: value[location[0]:location[1]]}) + position = location[1] + } + appendVisible(value[position:]) + return tokens +} + +func nodeTokensWidth(tokens []nodeWrapToken) int { + width := 0 + for _, token := range tokens { + width += token.width + } + return width +} + +func nodeTokensString(tokens []nodeWrapToken) string { + var value strings.Builder + for _, token := range tokens { + value.WriteString(token.raw) + } + return value.String() +} + +func splitNodeTokensAtBoundary(tokens []nodeWrapToken, width int) (line, rest []nodeWrapToken) { + visibleWidth := 0 + lastWhitespaceStart := -1 + lastWhitespaceEnd := -1 + + i := 0 + for i < len(tokens) { + if tokens[i].whitespace { + start := i + widthBeforeWhitespace := visibleWidth + for i < len(tokens) && tokens[i].whitespace { + visibleWidth += tokens[i].width + i++ + } + if widthBeforeWhitespace > 0 && widthBeforeWhitespace <= width { + lastWhitespaceStart = start + lastWhitespaceEnd = i + } + if visibleWidth > width { + break + } + continue + } + + if tokens[i].width > 0 && visibleWidth+tokens[i].width > width { + break + } + visibleWidth += tokens[i].width + i++ + } + if i == len(tokens) && visibleWidth <= width { + return tokens, nil + } + + if lastWhitespaceStart >= 0 { + line = tokens[:lastWhitespaceStart] + rest = tokens[lastWhitespaceEnd:] + if nodeTokensWidth(line) > 0 { + return line, rest + } + } + + visibleWidth = 0 + cut := 0 + hasVisibleToken := false + for cut < len(tokens) { + token := tokens[cut] + if token.width > 0 && visibleWidth+token.width > width { + if hasVisibleToken { + break + } + cut++ + hasVisibleToken = true + break + } + visibleWidth += token.width + if token.width > 0 { + hasVisibleToken = true + } + cut++ + } + for cut < len(tokens) && tokens[cut].width == 0 && !tokens[cut].whitespace { + cut++ + } + return tokens[:cut], tokens[cut:] +} + +func colorizeNodeLines(lines []string) []string { + state := nodeSGRState{styles: make(map[int]string)} + colored := make([]string, len(lines)) + for i, line := range lines { + line = state.prefix() + line + state.update(line) + colored[i] = line + state.suffix() + } + return colored +} + +func (s *nodeSGRState) update(line string) { + for _, match := range nodeSGRRegexp.FindAllStringSubmatch(line, -1) { + codes := []int{0} + if match[1] != "" { + codes = codes[:0] + for _, value := range strings.Split(match[1], ";") { + code, err := strconv.Atoi(value) + if err == nil { + codes = append(codes, code) + } + } + } + for i := 0; i < len(codes); i++ { + code := codes[i] + if code == 38 || code == 48 { + length := nodeExtendedColorLength(codes[i:]) + s.updateCode(code, nodeSGRSequence(codes[i:i+length])) + i += length - 1 + continue + } + s.updateCode(code, nodeSGRSequence([]int{code})) + } + } +} + +func nodeExtendedColorLength(codes []int) int { + if len(codes) < 2 { + return 1 + } + want := 1 + switch codes[1] { + case 2: + want = 5 + case 5: + want = 3 + } + if want > len(codes) { + return len(codes) + } + return want +} + +func nodeSGRSequence(codes []int) string { + var sequence strings.Builder + sequence.WriteString("\x1b[") + for i, code := range codes { + if i > 0 { + sequence.WriteByte(';') + } + sequence.WriteString(strconv.Itoa(code)) + } + sequence.WriteByte('m') + return sequence.String() +} + +func (s *nodeSGRState) updateCode(code int, raw string) { + switch { + case code == 0: + s.foreground = "" + s.background = "" + clear(s.styles) + case code == 1 || code == 2 || code == 3 || code == 4 || code == 5 || code == 7 || code == 8 || code == 9: + s.styles[code] = raw + case code == 22: + delete(s.styles, 1) + delete(s.styles, 2) + case code == 23: + delete(s.styles, 3) + case code == 24: + delete(s.styles, 4) + case code == 25: + delete(s.styles, 5) + case code == 27: + delete(s.styles, 7) + case code == 28: + delete(s.styles, 8) + case code == 29: + delete(s.styles, 9) + case (code >= 30 && code <= 38) || (code >= 90 && code <= 97): + s.foreground = raw + case code == 39: + s.foreground = "" + case (code >= 40 && code <= 48) || (code >= 100 && code <= 107): + s.background = raw + case code == 49: + s.background = "" + } +} + +func (s nodeSGRState) prefix() string { + var prefix strings.Builder + for _, code := range nodeStyleCodes { + prefix.WriteString(s.styles[code]) + } + prefix.WriteString(s.background) + prefix.WriteString(s.foreground) + return prefix.String() +} + +func (s nodeSGRState) suffix() string { + var suffix strings.Builder + for _, code := range nodeStyleCodes { + if s.styles[code] != "" { + suffix.WriteString(nodeStyleCloseCodes[code]) + } + } + if s.background != "" { + suffix.WriteString("\x1b[49m") + } + if s.foreground != "" { + suffix.WriteString("\x1b[39m") + } + return suffix.String() +} diff --git a/internal/output/table_layout_test.go b/internal/output/table_layout_test.go new file mode 100644 index 000000000..b0d43d90a --- /dev/null +++ b/internal/output/table_layout_test.go @@ -0,0 +1,107 @@ +package output + +import ( + "strings" + "testing" +) + +func TestNodeColumnWidthsPreserveShortColumnsAndShrinkLongest(t *testing.T) { + headers := []string{"timestamp", "message"} + rows := [][]string{{ + "2026-07-15T07:17:38.002797318Z", + strings.Repeat("long message ", 20), + }} + + got := nodeColumnWidths(headers, rows, 78) + want := []int{30, 41} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("nodeColumnWidths() = %v, want %v", got, want) + } + if width := nodeTableWidth(got); width != 78 { + t.Fatalf("nodeTableWidth() = %d, want 78", width) + } +} + +func TestNodeColumnWidthsFitManyColumns(t *testing.T) { + headers := []string{"timestamp", "rows sent", "rows examined", "query time", "request uri", "query"} + rows := [][]string{{ + "2026-07-15T07:17:38.002797318Z", "10", "1000", "1.234", + "/wp-admin/edit.php?post_type=very-long-value", + strings.Repeat("SELECT post_id FROM wp_posts ", 20), + }} + + widths := nodeColumnWidths(headers, rows, 78) + if width := nodeTableWidth(widths); width > 78 { + t.Fatalf("nodeTableWidth(%v) = %d, want <= 78", widths, width) + } +} + +func TestNodeColumnWidthsUseStructuralMinimumWhenTerminalIsTooNarrow(t *testing.T) { + widths := nodeColumnWidths([]string{"alpha", "beta", "gamma"}, [][]string{{"a", "b", "c"}}, 5) + want := []int{1, 1, 1} + if len(widths) != len(want) || widths[0] != 1 || widths[1] != 1 || widths[2] != 1 { + t.Fatalf("nodeColumnWidths() = %v, want %v", widths, want) + } +} + +func TestNodeColumnWidthsGiveEmptyColumnsOneDisplayCellWhenConstrained(t *testing.T) { + widths := nodeColumnWidths([]string{"", "message"}, [][]string{{"", strings.Repeat("wide ", 20)}}, 12) + if len(widths) != 2 || widths[0] != 1 { + t.Fatalf("nodeColumnWidths() = %v, want empty constrained column width 1", widths) + } +} + +func TestNodeColumnWidthsKeepNaturalEmptyColumnZeroWithoutConstraint(t *testing.T) { + widths := nodeColumnWidths([]string{""}, [][]string{{""}}, 0) + if len(widths) != 1 || widths[0] != 0 { + t.Fatalf("nodeColumnWidths() = %v, want original natural width [0]", widths) + } +} + +func TestWrapNodeCellUsesWordsAndHardWrapsLongTokens(t *testing.T) { + if got, want := wrapNodeCell("alpha beta gamma", 10), []string{"alpha beta", "gamma"}; strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("word wrap = %#v, want %#v", got, want) + } + if got, want := wrapNodeCell("abcdefghijk", 5), []string{"abcde", "fghij", "k"}; strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("hard wrap = %#v, want %#v", got, want) + } +} + +func TestWrapNodeCellPreservesExplicitNewlinesAndUnicodeWidth(t *testing.T) { + if got, want := wrapNodeCell("alpha\n\nbeta", 20), []string{"alpha", "", "beta"}; strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("explicit lines = %#v, want %#v", got, want) + } + if got, want := wrapNodeCell("界界界", 4), []string{"界界", "界"}; strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("Unicode wrap = %#v, want %#v", got, want) + } +} + +func TestWrapNodeCellPreservesANSIStateAcrossGeneratedLines(t *testing.T) { + got := wrapNodeCell("\x1b[31malpha beta gamma\x1b[39m", 10) + if len(got) != 2 { + t.Fatalf("wrapped lines = %#v, want 2 lines", got) + } + if stripNodeANSI(got[0]) != "alpha beta" || stripNodeANSI(got[1]) != "gamma" { + t.Fatalf("visible wrapped lines = %#v", got) + } + for i, line := range got { + if !strings.Contains(line, "\x1b[31m") || !strings.Contains(line, "\x1b[39m") { + t.Fatalf("line %d does not contain balanced foreground state: %q", i, line) + } + } +} + +func TestWrapNodeCellPreservesTrueColorANSIStateAcrossGeneratedLines(t *testing.T) { + const open = "\x1b[38;2;255;31;0m" + const close = "\x1b[39m" + + got := wrapNodeCell(open+"alpha beta gamma"+close, 10) + if len(got) != 2 { + t.Fatalf("wrapped lines = %#v, want 2 lines", got) + } + for i, line := range got { + if strings.Count(line, open) != 1 || strings.Count(line, close) != 1 { + t.Fatalf("line %d does not contain one balanced true-color state: %q", i, line) + } + } +} diff --git a/internal/output/table_layout_tty_test.go b/internal/output/table_layout_tty_test.go new file mode 100644 index 000000000..37fdfa14d --- /dev/null +++ b/internal/output/table_layout_tty_test.go @@ -0,0 +1,63 @@ +//go:build !windows + +package output + +import ( + "bufio" + "strings" + "testing" + + "github.com/creack/pty" +) + +func TestTerminalTableWidthUsesTTYColumnsWithSafetyMargin(t *testing.T) { + primary, replica, err := pty.Open() + if err != nil { + t.Fatal(err) + } + defer func() { _ = primary.Close() }() + defer func() { _ = replica.Close() }() + + if err := pty.Setsize(replica, &pty.Winsize{Cols: 80, Rows: 24}); err != nil { + t.Fatal(err) + } + if got := terminalTableWidth(replica); got != 78 { + t.Fatalf("terminalTableWidth() = %d, want 78", got) + } + if !terminalTableIsTTY(replica) { + t.Fatal("terminalTableIsTTY(pty) = false, want true") + } +} + +// The other half of the TTY gate: written to a real terminal, the table keeps +// the grey border and bright-blue head. Without this, "strip ANSI when not a +// TTY" could be satisfied by stripping it everywhere. +func TestRenderNodeTableToTTYKeepsANSI(t *testing.T) { + primary, replica, err := pty.Open() + if err != nil { + t.Fatal(err) + } + defer func() { _ = primary.Close() }() + defer func() { _ = replica.Close() }() + + if err := pty.Setsize(replica, &pty.Winsize{Cols: 80, Rows: 24}); err != nil { + t.Fatal(err) + } + + // Read concurrently: a pty has a small kernel buffer and the writer would + // block once it fills. + lines := make(chan string, 1) + go func() { + reader := bufio.NewReader(primary) + line, _ := reader.ReadString('\n') + lines <- line + }() + + if err := renderNodeTable(replica, []string{"id"}, [][]string{{"1"}}); err != nil { + t.Fatal(err) + } + first := <-lines + if !strings.Contains(first, "\x1b[90m") { + t.Fatalf("table written to a TTY lost its border colour: %q", first) + } +} diff --git a/internal/output/text.go b/internal/output/text.go new file mode 100644 index 000000000..f39daf0e4 --- /dev/null +++ b/internal/output/text.go @@ -0,0 +1,24 @@ +package output + +import ( + "fmt" + "io" + "strings" +) + +func renderText(w io.Writer, data any) error { + rows, ok := data.(OrderedRows) + if !ok { + return fmt.Errorf("text renderer requires OrderedRows, got %T", data) + } + for _, r := range rows { + parts := make([]string, 0, len(r)) + for _, c := range r { + parts = append(parts, fmt.Sprint(c.Value)) + } + if _, err := fmt.Fprintln(w, strings.Join(parts, " ")); err != nil { + return err + } + } + return nil +} diff --git a/internal/output/text_test.go b/internal/output/text_test.go new file mode 100644 index 000000000..842f31a74 --- /dev/null +++ b/internal/output/text_test.go @@ -0,0 +1,31 @@ +package output + +import ( + "bytes" + "testing" +) + +func TestRenderTextOrderedRows(t *testing.T) { + var buf bytes.Buffer + rows := OrderedRows{ + {{Key: "timestamp", Value: "2026-06-08T00:00:00Z"}, {Key: "message", Value: "hello"}}, + {{Key: "timestamp", Value: "2026-06-08T00:00:01Z"}, {Key: "message", Value: "world"}}, + } + if err := renderText(&buf, rows); err != nil { + t.Fatalf("renderText: %v", err) + } + want := "2026-06-08T00:00:00Z hello\n2026-06-08T00:00:01Z world\n" + if buf.String() != want { + t.Errorf("got %q, want %q", buf.String(), want) + } +} + +func TestRenderTextEmpty(t *testing.T) { + var buf bytes.Buffer + if err := renderText(&buf, OrderedRows{}); err != nil { + t.Fatalf("renderText: %v", err) + } + if buf.Len() != 0 { + t.Errorf("empty input must produce empty output; got %q", buf.String()) + } +} diff --git a/internal/output/typename.go b/internal/output/typename.go new file mode 100644 index 000000000..75ed96b09 --- /dev/null +++ b/internal/output/typename.go @@ -0,0 +1,35 @@ +package output + +import ( + json "encoding/json/v2" +) + +// StripTypename decodes the input into a generic structure, recursively +// removes every "__typename" key, and re-encodes. Used by the gql layer +// to clean responses before they reach command handlers. +func StripTypename(in []byte) ([]byte, error) { + var doc any + if err := json.Unmarshal(in, &doc); err != nil { + return nil, err + } + stripWalk(&doc) + return json.Marshal(doc, json.Deterministic(true)) +} + +func stripWalk(v *any) { + switch t := (*v).(type) { + case map[string]any: + delete(t, "__typename") + for k := range t { + child := t[k] + stripWalk(&child) + t[k] = child + } + case []any: + for i := range t { + child := t[i] + stripWalk(&child) + t[i] = child + } + } +} diff --git a/internal/output/typename_test.go b/internal/output/typename_test.go new file mode 100644 index 000000000..86cb78558 --- /dev/null +++ b/internal/output/typename_test.go @@ -0,0 +1,48 @@ +package output + +import ( + "strings" + "testing" +) + +func TestStripTypenameRemovesField(t *testing.T) { + in := `{"id":1,"name":"alpha","__typename":"App"}` + got, err := StripTypename([]byte(in)) + if err != nil { + t.Fatalf("StripTypename: %v", err) + } + if strings.Contains(string(got), "__typename") { + t.Errorf("__typename not removed: %s", got) + } + if !strings.Contains(string(got), `"name":"alpha"`) { + t.Errorf("other fields lost: %s", got) + } +} + +func TestStripTypenameRecursive(t *testing.T) { + in := `{"a":{"__typename":"X","b":[{"__typename":"Y","c":2}]}}` + got, err := StripTypename([]byte(in)) + if err != nil { + t.Fatalf("StripTypename: %v", err) + } + if strings.Count(string(got), "__typename") != 0 { + t.Errorf("nested __typename not removed: %s", got) + } + if !strings.Contains(string(got), `"c":2`) { + t.Errorf("leaf data lost: %s", got) + } +} + +func TestStripTypenamePreservesArrays(t *testing.T) { + in := `{"items":[{"id":1,"__typename":"A"},{"id":2,"__typename":"B"}]}` + got, err := StripTypename([]byte(in)) + if err != nil { + t.Fatalf("StripTypename: %v", err) + } + if strings.Contains(string(got), "__typename") { + t.Errorf("__typename in array not removed: %s", got) + } + if !strings.Contains(string(got), `"id":1`) || !strings.Contains(string(got), `"id":2`) { + t.Errorf("array entries lost: %s", got) + } +} diff --git a/internal/poll/poll.go b/internal/poll/poll.go new file mode 100644 index 000000000..eaf937c48 --- /dev/null +++ b/internal/poll/poll.go @@ -0,0 +1,85 @@ +// Package poll ports Node's pollUntil helper (src/lib/utils.ts:9-35) — the +// shared ceiling every long-running VIP poll loop is supposed to sit under. +// +// Node: +// +// export class PollingTimeoutError extends Error {} +// +// export async function pollUntil< T >( +// fn: () => Promise< T >, +// interval: number, +// isDone: ( v: T ) => boolean, +// timeoutMs: number = 6 * 60 * 60 * 1000 // Default to 6 hours +// ) { +// const startTime = Date.now(); +// while ( Date.now() - startTime < timeoutMs ) { +// const result = await fn(); +// if ( isDone( result ) ) { return result; } +// await setTimeout( interval ); +// } +// throw new PollingTimeoutError( 'Polling timed out' ); +// } +// +// Two shape details are load-bearing and deliberately reproduced: +// +// 1. The deadline is evaluated at the TOP of the loop, before `fn` runs, so +// a non-positive ceiling never calls fn at all. +// 2. The sleep happens only after a not-done result, so the terminal check +// is never delayed by one interval. +// +// The one addition over Node is context cancellation: Node has no ctx, but a +// Go poll loop that ignores it cannot be interrupted. +package poll + +import ( + "context" + "errors" + "time" +) + +// DefaultTimeout is Node's pollUntil ceiling (utils.ts:18): 6 hours. Callers +// that pass a zero timeout to Until get this. +const DefaultTimeout = 6 * time.Hour + +// ErrTimeout ports PollingTimeoutError (utils.ts:9,34). The message matches +// Node's exactly because several callers surface it verbatim to the user. +var ErrTimeout = errors.New("Polling timed out") + +// Until calls fn every interval until isDone accepts its result, giving up +// with ErrTimeout once timeout has elapsed. A zero (or negative) interval +// polls without sleeping. timeout is taken literally — Go cannot tell an +// omitted argument from an explicit 0 the way Node's default parameter can, +// so callers resolve DefaultTimeout themselves (same pattern they already +// use for the interval). An error from fn aborts immediately and is returned +// unwrapped, matching Node: a rejection inside pollUntil propagates rather +// than being retried. +func Until[T any]( + ctx context.Context, + fn func(context.Context) (T, error), + interval time.Duration, + isDone func(T) bool, + timeout time.Duration, +) (T, error) { + var zero T + start := time.Now() + for time.Since(start) < timeout { + v, err := fn(ctx) + if err != nil { + return zero, err + } + if isDone(v) { + return v, nil + } + if interval <= 0 { + continue + } + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + return zero, ctx.Err() + case <-timer.C: + } + } + return zero, ErrTimeout +} diff --git a/internal/poll/poll_test.go b/internal/poll/poll_test.go new file mode 100644 index 000000000..857025db4 --- /dev/null +++ b/internal/poll/poll_test.go @@ -0,0 +1,136 @@ +package poll + +import ( + "context" + "errors" + "testing" + "time" +) + +// TestDefaultTimeoutIsNodesSixHourCeiling pins the ceiling value itself +// (src/lib/utils.ts:18 — `timeoutMs: number = 6 * 60 * 60 * 1000`). +func TestDefaultTimeoutIsNodesSixHourCeiling(t *testing.T) { + if DefaultTimeout != 6*time.Hour { + t.Errorf("DefaultTimeout = %v, want 6h (utils.ts:18)", DefaultTimeout) + } +} + +// TestUntilReturnsResultWhenDone is the happy path: fn is retried until +// isDone accepts the value, and that value is returned. +func TestUntilReturnsResultWhenDone(t *testing.T) { + calls := 0 + got, err := Until(context.Background(), + func(context.Context) (string, error) { + calls++ + if calls < 3 { + return "pending", nil + } + return "done", nil + }, + time.Millisecond, + func(v string) bool { return v == "done" }, + time.Minute, + ) + if err != nil { + t.Fatalf("Until: %v", err) + } + if got != "done" { + t.Errorf("result = %q, want %q", got, "done") + } + if calls != 3 { + t.Errorf("fn calls = %d, want 3", calls) + } +} + +// TestUntilStopsAtCeiling is the regression test for the unbounded poll +// loops. fn NEVER reports done; the loop must still terminate on its own +// once the ceiling elapses, and it must terminate by returning ErrTimeout +// rather than by being cancelled from outside. +// +// Before the fix there was no ceiling at all, so this test hangs forever +// (the harness below turns that into a failure instead of a wedged run). +func TestUntilStopsAtCeiling(t *testing.T) { + calls := 0 + type result struct { + err error + } + done := make(chan result, 1) + start := time.Now() + go func() { + _, err := Until(context.Background(), + func(context.Context) (string, error) { calls++; return "pending", nil }, + 5*time.Millisecond, + func(string) bool { return false }, + 60*time.Millisecond, + ) + done <- result{err} + }() + + select { + case r := <-done: + if !errors.Is(r.err, ErrTimeout) { + t.Fatalf("err = %v, want ErrTimeout", r.err) + } + if r.err.Error() != "Polling timed out" { + t.Errorf("err.Error() = %q, want %q (utils.ts:34)", r.err.Error(), "Polling timed out") + } + if elapsed := time.Since(start); elapsed < 60*time.Millisecond { + t.Errorf("returned after %v, want >= the 60ms ceiling", elapsed) + } + if calls == 0 { + t.Error("fn was never called") + } + case <-time.After(5 * time.Second): + t.Fatal("Until never returned: the poll loop is unbounded") + } +} + +// TestUntilChecksDeadlineBeforeCallingFn matches Node's loop shape: the +// `while ( Date.now() - startTime < timeoutMs )` guard is evaluated BEFORE +// the first `await fn()`, so a non-positive ceiling never calls fn at all. +func TestUntilChecksDeadlineBeforeCallingFn(t *testing.T) { + calls := 0 + _, err := Until(context.Background(), + func(context.Context) (string, error) { calls++; return "done", nil }, + time.Millisecond, + func(string) bool { return true }, + 0, + ) + if !errors.Is(err, ErrTimeout) { + t.Fatalf("err = %v, want ErrTimeout", err) + } + if calls != 0 { + t.Errorf("fn calls = %d, want 0 (Node checks the deadline first)", calls) + } +} + +// TestUntilPropagatesFnError: a failing fn aborts the poll instead of being +// retried, matching a rejected promise inside Node's pollUntil. +func TestUntilPropagatesFnError(t *testing.T) { + sentinel := errors.New("boom") + _, err := Until(context.Background(), + func(context.Context) (string, error) { return "", sentinel }, + time.Millisecond, + func(string) bool { return true }, + time.Minute, + ) + if !errors.Is(err, sentinel) { + t.Errorf("err = %v, want the fn error", err) + } +} + +// TestUntilHonoursContextCancellation — Go-only addition (Node's pollUntil +// has no cancellation): a cancelled context aborts the wait immediately. +func TestUntilHonoursContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + go func() { time.Sleep(10 * time.Millisecond); cancel() }() + _, err := Until(ctx, + func(context.Context) (string, error) { return "pending", nil }, + 50*time.Millisecond, + func(string) bool { return false }, + time.Hour, + ) + if !errors.Is(err, context.Canceled) { + t.Errorf("err = %v, want context.Canceled", err) + } +} diff --git a/internal/polling/polling.go b/internal/polling/polling.go new file mode 100644 index 000000000..44df93864 --- /dev/null +++ b/internal/polling/polling.go @@ -0,0 +1,97 @@ +package polling + +import ( + "context" + "fmt" + "os" + "time" +) + +// Opts configures Loop behavior. +type Opts struct { + InitialLimit int // limit used on the first fetch + FollowLimit int // limit used on subsequent fetches (Node: LIMIT_MAX) + DefaultInterval time.Duration // sleep when server doesn't hint a delay + ServerHintMin time.Duration // floor for server-hinted delay + ServerHintMax time.Duration // ceiling for delay (errors also capped here) + ErrorBackoffStep time.Duration // added per consecutive error +} + +// Page is what a fetch returns. +type Page struct { + Render func() error + NextCursor *string + PollingDelaySecs int +} + +// Fetch is the caller-supplied page fetcher. +type Fetch func(ctx context.Context, after *string, limit int) (Page, error) + +// Loop fetches pages forever (or until ctx cancellation). First-call error +// returns immediately (Node parity). Subsequent errors back off and continue. +func Loop(ctx context.Context, opts Opts, fetch Fetch) error { + if opts.DefaultInterval == 0 { + opts.DefaultInterval = 30 * time.Second + } + if opts.ServerHintMin == 0 { + opts.ServerHintMin = 5 * time.Second + } + if opts.ServerHintMax == 0 { + opts.ServerHintMax = 5 * time.Minute + } + if opts.ErrorBackoffStep == 0 { + opts.ErrorBackoffStep = 30 * time.Second + } + if opts.FollowLimit == 0 { + opts.FollowLimit = opts.InitialLimit + } + + var ( + after *string + firstCall = true + delay = opts.DefaultInterval + ) + + for { + limit := opts.InitialLimit + if !firstCall { + limit = opts.FollowLimit + } + page, err := fetch(ctx, after, limit) + if err != nil { + if firstCall { + return err + } + delay += opts.ErrorBackoffStep + if delay > opts.ServerHintMax { + delay = opts.ServerHintMax + } + fmt.Fprintf(os.Stderr, "Error: Failed to fetch. Trying again in %d seconds.\n", int(delay.Seconds())) + } else { + if page.Render != nil { + if rerr := page.Render(); rerr != nil { + return rerr + } + } + after = page.NextCursor + firstCall = false + if page.PollingDelaySecs > 0 { + delay = time.Duration(page.PollingDelaySecs) * time.Second + } else { + delay = opts.DefaultInterval + } + if delay < opts.ServerHintMin { + delay = opts.ServerHintMin + } + if delay > opts.ServerHintMax { + delay = opts.ServerHintMax + } + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(delay): + } + } +} diff --git a/internal/polling/polling_test.go b/internal/polling/polling_test.go new file mode 100644 index 000000000..1b5e5651a --- /dev/null +++ b/internal/polling/polling_test.go @@ -0,0 +1,59 @@ +package polling + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestLoopFirstCallErrorExits(t *testing.T) { + opts := Opts{InitialLimit: 100, FollowLimit: 5000, DefaultInterval: 30 * time.Second} + fetch := func(ctx context.Context, after *string, limit int) (Page, error) { + return Page{}, errors.New("network down") + } + err := Loop(context.Background(), opts, fetch) + if err == nil { + t.Error("first-call error must propagate (Node parity)") + } +} + +func TestLoopUsesInitialLimitThenFollowLimit(t *testing.T) { + opts := Opts{InitialLimit: 100, FollowLimit: 5000, DefaultInterval: 1 * time.Millisecond, ServerHintMin: 1 * time.Millisecond, ServerHintMax: 1 * time.Millisecond} + var seenLimits []int + fetch := func(ctx context.Context, after *string, limit int) (Page, error) { + seenLimits = append(seenLimits, limit) + if len(seenLimits) >= 3 { + return Page{}, context.Canceled + } + return Page{ + Render: func() error { return nil }, + NextCursor: nil, + PollingDelaySecs: 0, + }, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _ = Loop(ctx, opts, fetch) + if len(seenLimits) < 2 { + t.Fatalf("expected at least 2 calls; got %d", len(seenLimits)) + } + if seenLimits[0] != 100 { + t.Errorf("first call limit = %d, want 100", seenLimits[0]) + } + if seenLimits[1] != 5000 { + t.Errorf("second call limit = %d, want 5000 (FollowLimit)", seenLimits[1]) + } +} + +func TestLoopRespectsContextCancellation(t *testing.T) { + opts := Opts{InitialLimit: 100, FollowLimit: 5000, DefaultInterval: 50 * time.Millisecond} + fetch := func(ctx context.Context, after *string, limit int) (Page, error) { + return Page{Render: func() error { return nil }}, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + if err := Loop(ctx, opts, fetch); err != nil && !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) { + t.Errorf("expected ctx cancellation error, got %v", err) + } +} diff --git a/internal/redact/redact.go b/internal/redact/redact.go new file mode 100644 index 000000000..a1c75551b --- /dev/null +++ b/internal/redact/redact.go @@ -0,0 +1,99 @@ +// Package redact removes credentials from text that is about to leave the +// process — an error message printed to a shared terminal, written to a log +// file, or, in vip-next's case, shipped to an analytics endpoint by the +// cli_error telemetry hook. +// +// It is the production counterpart of internal/parity's RedactSecrets, which is +// test-only and takes the secrets it should remove as arguments. Here the +// secrets are not known in advance: they arrive inside error strings minted by +// net/http, which embeds the full request URL — query string and all — in every +// *url.Error it returns. +// +// The design constraint is that this must be safe to apply unconditionally. A +// scrubber that mangles ordinary messages produces unreadable errors and gets +// switched off, so every rule here is anchored on a shape that does not occur +// in prose: a URL's query or userinfo, a JWT's "eyJ" header prefix, an explicit +// Bearer keyword. +package redact + +import ( + "net/url" + "regexp" + "strings" +) + +const ( + placeholderQuery = "" + placeholderUserinfo = "xxxxx" + placeholderJWT = "" +) + +// urlRE matches an absolute URL up to the first character that cannot appear in +// one unescaped. Quotes are terminators because net/http quotes the URL in +// *url.Error: `Get "https://…": dial tcp …`. +var urlRE = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9+.\-]*://[^\s"'` + "`" + `<>]+`) + +// jwtRE is anchored on "eyJ", the base64 of `{"` that opens every JWT header. +// +// The looser `<8+>.<8+>.` shape internal/parity uses is wrong for +// production text: it matches hostnames. "public-api.wordpress.com" satisfies +// it, and redacting the API host out of every network error would make the +// telemetry useless and the local message baffling. +var jwtRE = regexp.MustCompile(`eyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}(?:\.[A-Za-z0-9_-]+)?`) + +// bearerRE catches a token that reached the message through a header dump +// rather than a URL. +var bearerRE = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{8,}`) + +// trailingPunctuation is stripped from a URL match before parsing and restored +// after, so "see https://x/y?t=1." does not fold the sentence's full stop into +// the URL. +const trailingPunctuation = `.,;:!)]}` + +// Text returns s with every credential-shaped substring replaced. +// +// Removed: URL query strings (where presigned credentials live), URL userinfo +// (proxy passwords), URL fragments (implicit-flow tokens), JWTs, and Bearer +// tokens. Preserved: scheme, host, port and path of every URL, and all +// surrounding prose — the parts that make an error diagnosable. +func Text(s string) string { + s = urlRE.ReplaceAllStringFunc(s, redactURL) + s = jwtRE.ReplaceAllString(s, placeholderJWT) + s = bearerRE.ReplaceAllString(s, "Bearer "+placeholderQuery) + return s +} + +func redactURL(match string) string { + trimmed := strings.TrimRight(match, trailingPunctuation) + suffix := match[len(trimmed):] + + u, err := url.Parse(trimmed) + if err != nil { + // Unparseable, but a "?" still means everything after it is a query. + // Cut textually rather than let a malformed URL smuggle a signature out. + if q := strings.Index(trimmed, "?"); q >= 0 { + return trimmed[:q] + "?" + placeholderQuery + suffix + } + return match + } + + changed := false + if u.User != nil { + u.User = url.User(placeholderUserinfo) + changed = true + } + if u.RawQuery != "" && u.RawQuery != placeholderQuery { + // RawQuery is emitted verbatim by URL.String(), so the placeholder + // survives as written and re-running Text is a no-op. + u.RawQuery = placeholderQuery + changed = true + } + if u.Fragment != "" && u.Fragment != placeholderQuery { + u.Fragment = placeholderQuery + changed = true + } + if !changed { + return match + } + return u.String() + suffix +} diff --git a/internal/redact/redact_test.go b/internal/redact/redact_test.go new file mode 100644 index 000000000..78b2bc50f --- /dev/null +++ b/internal/redact/redact_test.go @@ -0,0 +1,80 @@ +package redact + +import ( + "strings" + "testing" +) + +func TestTextStripsURLQueryStrings(t *testing.T) { + // The presigned URLs vip-next handles — media-import error reports, SQL + // export downloads, upload presigns — put the credential IN the query + // string. Possession of the query is the authorisation. + in := `Get "https://vip-media.s3.amazonaws.com/report.json?X-Amz-Signature=deadbeefcafe&X-Amz-Credential=AKIAEXAMPLE": dial tcp: i/o timeout` + got := Text(in) + + for _, secret := range []string{"X-Amz-Signature", "deadbeefcafe", "AKIAEXAMPLE"} { + if strings.Contains(got, secret) { + t.Errorf("Text kept %q:\n\t%s", secret, got) + } + } + // The diagnosable parts must survive: which host, which object, what failed. + for _, keep := range []string{"vip-media.s3.amazonaws.com", "report.json", "i/o timeout"} { + if !strings.Contains(got, keep) { + t.Errorf("Text dropped %q, which the report needs to stay useful:\n\t%s", keep, got) + } + } +} + +func TestTextStripsURLUserinfo(t *testing.T) { + got := Text("SOCKS proxy socks5://alice:hunter2@proxy.corp.example:1080 has no host") + if strings.Contains(got, "hunter2") || strings.Contains(got, "alice") { + t.Errorf("Text kept proxy credentials:\n\t%s", got) + } + if !strings.Contains(got, "proxy.corp.example:1080") { + t.Errorf("Text dropped the proxy host, which the user needs to fix their config:\n\t%s", got) + } +} + +func TestTextStripsJWTs(t *testing.T) { + jwt := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk" + got := Text("token rejected: " + jwt) + if strings.Contains(got, jwt) || strings.Contains(got, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9") { + t.Errorf("Text kept a JWT:\n\t%s", got) + } + if !strings.Contains(got, "token rejected") { + t.Errorf("Text dropped the surrounding message:\n\t%s", got) + } +} + +func TestTextStripsBearerTokens(t *testing.T) { + got := Text("request failed with header Authorization: Bearer abc123SECRETvalue.and-more") + if strings.Contains(got, "abc123SECRETvalue") { + t.Errorf("Text kept a bearer token:\n\t%s", got) + } +} + +// TestTextLeavesOrdinaryMessagesAlone is the counterweight. A scrubber that +// mangles every message is one people will disable. Hostnames in particular +// look JWT-ish to a naive `a.b.c` regex — internal/parity's RedactSecrets uses +// exactly such a pattern, and it would eat "public-api.wordpress.com". +func TestTextLeavesOrdinaryMessagesAlone(t *testing.T) { + for _, msg := range []string{ + "failed to reach public-api.wordpress.com: connection refused", + "environment my-site is not running; run `vip dev-env start`", + "GraphQL error: You do not have permission to access this application", + "open versions.json: no such file or directory", + "https://api.wpvip.com/graphql returned 502", + } { + if got := Text(msg); got != msg { + t.Errorf("Text rewrote an innocuous message:\n\tin: %s\n\tout: %s", msg, got) + } + } +} + +func TestTextIsIdempotent(t *testing.T) { + in := `Get "https://example.com/a?sig=abc": refused` + once := Text(in) + if twice := Text(once); twice != once { + t.Errorf("Text is not idempotent:\n\t1x: %s\n\t2x: %s", once, twice) + } +} diff --git a/internal/tui/progress.go b/internal/tui/progress.go new file mode 100644 index 000000000..318eee74c --- /dev/null +++ b/internal/tui/progress.go @@ -0,0 +1,153 @@ +// Package tui hosts terminal UI primitives shared across commands. +// +// MultiLineRenderer drives in-place spinner/step-list rendering (vip +// sync's progress display today; future heavy commands such as backup +// progress and SQL-import progress will share it). Tested in isolation +// so the per-command callers stay free of ANSI string-building. +// +// Scope is intentionally narrow: this package hosts UI primitives, not a +// widget library. ProgressTracker (progress_tracker.go) is the shared +// step-list/spinner port of Node's lib/cli/progress.ts used by the heavy +// commands; truly command-specific framing still lives with callers in +// cmd/. +package tui + +import ( + "fmt" + "io" + "regexp" + + "golang.org/x/term" +) + +// ansiSGRRe matches CSI escape sequences (colors etc.) so visibleWidth can +// measure the on-screen width of a colorized line. +var ansiSGRRe = regexp.MustCompile("\x1b\\[[0-9;]*[A-Za-z]") + +// visibleWidth is the number of on-screen columns a line occupies: its rune +// count with ANSI escape sequences stripped (the step glyphs are color-wrapped, +// e.g. a green ✓, which must not inflate the width). +func visibleWidth(s string) int { + return len([]rune(ansiSGRRe.ReplaceAllString(s, ""))) +} + +// MultiLineRenderer rewrites a multi-line block in place when attached +// to a TTY, falling back to plain append on non-TTY writers. +// +// Concurrency: not safe for concurrent use; callers must serialize +// Render/Done. +type MultiLineRenderer struct { + w io.Writer + tty bool + // fd is the terminal file descriptor used to query the width, or -1 when + // the writer is not a terminal file (e.g. a bytes.Buffer in tests). + fd int + // width, when > 0, overrides the queried terminal width (test seam). + width int + // lastRows is the number of PHYSICAL rows the previous frame occupied — + // long lines wrap, so this is not the same as the logical line count. + lastRows int +} + +// NewMultiLineRenderer constructs a renderer. When tty is false the +// renderer never emits ANSI escape sequences and Render simply appends +// each frame's lines to w (CI / pipe behavior). On a TTY, if w exposes a +// terminal file descriptor the renderer becomes width-aware so wrapped +// lines are cleared correctly. +func NewMultiLineRenderer(w io.Writer, tty bool) *MultiLineRenderer { + r := &MultiLineRenderer{w: w, tty: tty, fd: -1} + if tty { + if f, ok := w.(interface{ Fd() uintptr }); ok { + r.fd = int(f.Fd()) + } + } + return r +} + +// cols returns the current terminal width, or 0 when it can't be determined +// (in which case rendering falls back to counting logical lines). +func (r *MultiLineRenderer) cols() int { + if r.width > 0 { + return r.width + } + if r.fd >= 0 { + if c, _, err := term.GetSize(r.fd); err == nil && c > 0 { + return c + } + } + return 0 +} + +// physicalRows is the number of screen rows a frame occupies once long lines +// wrap at the terminal width. When the width is unknown it degrades to the +// logical line count (the pre-width-aware behavior, fine for non-wrapping +// callers and buffer-backed tests). +func (r *MultiLineRenderer) physicalRows(lines []string) int { + c := r.cols() + if !r.tty || c <= 0 { + return len(lines) + } + rows := 0 + for _, line := range lines { + w := visibleWidth(line) + if w == 0 { + rows++ // an empty line still occupies one row + } else { + rows += (w + c - 1) / c // ceil(w / cols) + } + } + return rows +} + +// Render writes a frame. On TTY, subsequent calls overwrite the +// previously rendered block by moving the cursor up and erasing each +// prior PHYSICAL row before re-emitting. On non-TTY, every call appends. +// +// The frame is always terminated with newlines so the cursor lands on a +// fresh line, which keeps the math simple for the next call (we know the +// cursor is lastRows below the frame's first row). Because a line longer +// than the terminal wraps onto multiple rows, the cursor movement counts +// physical rows, not logical lines — counting lines leaves the wrapped +// remainder on screen, which is the "repeated lines" progress bug. +func (r *MultiLineRenderer) Render(lines []string) { + if r.tty && r.lastRows > 0 { + // Move cursor up to the first row of the previous frame. + // \033[F moves up n rows and parks at column 1. + fmt.Fprintf(r.w, "\033[%dF", r.lastRows) + // Erase each previous row. \033[2K clears the entire line; + // \033[1B moves down one line without scrolling. We deliberately + // don't combine these into a single "clear-from-cursor-to-end" + // (\033[J) because that also nukes anything below — and on some + // terminals (notably tmux) it can leave artifacts when the new + // frame is shorter than the old one. + for i := 0; i < r.lastRows; i++ { + fmt.Fprint(r.w, "\033[2K") + if i < r.lastRows-1 { + fmt.Fprint(r.w, "\033[1B") + } + } + // Cursor is now on the last cleared row. Move back up to the + // first cleared row so the upcoming Fprintln calls overwrite + // from the top. lastRows-1 because we're already on the last + // of the n cleared rows. + if r.lastRows > 1 { + fmt.Fprintf(r.w, "\033[%dF", r.lastRows-1) + } else { + // Single-row case: we're sitting on the cleared row at + // column 1, ready to write — no further movement needed. + fmt.Fprint(r.w, "\r") + } + } + for _, line := range lines { + fmt.Fprintln(r.w, line) + } + r.lastRows = r.physicalRows(lines) +} + +// Done resets internal state so the next Render writes a fresh frame +// rather than trying to overwrite the (now-finalized) previous one. +// Callers invoke this after they've printed terminal-state output and +// want subsequent writes to flow naturally. +func (r *MultiLineRenderer) Done() { + r.lastRows = 0 +} diff --git a/internal/tui/progress_test.go b/internal/tui/progress_test.go new file mode 100644 index 000000000..45266c58a --- /dev/null +++ b/internal/tui/progress_test.go @@ -0,0 +1,89 @@ +package tui + +import ( + "bytes" + "strings" + "testing" +) + +func TestMultiLineRendererFirstFrame(t *testing.T) { + var buf bytes.Buffer + r := NewMultiLineRenderer(&buf, true /*tty*/) + r.Render([]string{"step1", "step2", "step3"}) + out := buf.String() + if !strings.Contains(out, "step1") || !strings.Contains(out, "step2") || !strings.Contains(out, "step3") { + t.Errorf("first frame must write all lines; got %q", out) + } + // No cursor-up sequence on first frame. + if strings.Contains(out, "\033[3F") || strings.Contains(out, "\033[3A") { + t.Errorf("first frame must not emit cursor-up; got %q", out) + } +} + +func TestMultiLineRendererSubsequentFrameRedraws(t *testing.T) { + var buf bytes.Buffer + r := NewMultiLineRenderer(&buf, true) + r.Render([]string{"a", "b"}) + buf.Reset() + r.Render([]string{"a'", "b'"}) + out := buf.String() + if !strings.Contains(out, "\033[") { + t.Errorf("second frame must emit ANSI cursor manipulation; got %q", out) + } + if !strings.Contains(out, "a'") || !strings.Contains(out, "b'") { + t.Errorf("second frame must include new lines; got %q", out) + } +} + +func TestMultiLineRendererNonTTYWritesLinesNoANSI(t *testing.T) { + var buf bytes.Buffer + r := NewMultiLineRenderer(&buf, false /*non-tty*/) + r.Render([]string{"a", "b"}) + r.Render([]string{"c", "d"}) + out := buf.String() + if strings.Contains(out, "\033[") { + t.Errorf("non-TTY must emit zero ANSI escapes; got %q", out) + } + for _, want := range []string{"a", "b", "c", "d"} { + if !strings.Contains(out, want) { + t.Errorf("non-TTY output missing %q; got %q", want, out) + } + } +} + +// TestMultiLineRendererWidthAwareCursorUp is the regression for the sync/import +// progress "repeated lines" bug: a line longer than the terminal width wraps to +// multiple physical rows, so the cursor must move up by PHYSICAL rows, not +// logical lines. With width 40, ["short", 100×'A'] occupies 1 + ceil(100/40)=3 +// = 4 physical rows; the redraw must emit \033[4F, not \033[2F. +func TestMultiLineRendererWidthAwareCursorUp(t *testing.T) { + var buf bytes.Buffer + r := NewMultiLineRenderer(&buf, true /*tty*/) + r.width = 40 // test seam (same package): pretend the terminal is 40 cols + + frame := []string{"short", strings.Repeat("A", 100)} + r.Render(frame) + buf.Reset() + r.Render(frame) + out := buf.String() + + if !strings.Contains(out, "\033[4F") { + t.Errorf("redraw must move up 4 physical rows (\\033[4F); got %q", out) + } + if strings.Contains(out, "\033[2F") { + t.Errorf("redraw must NOT move up by logical line count (\\033[2F); got %q", out) + } +} + +// TestVisibleWidthStripsANSI ensures colorized glyphs don't inflate the width +// (the step glyphs are color-wrapped, e.g. green ✓), which would otherwise +// over-count physical rows. +func TestVisibleWidthStripsANSI(t *testing.T) { + // "\033[32m✓\033[0m ok" → visible "✓ ok" = 4 runes. + if got := visibleWidth("\033[32m✓\033[0m ok"); got != 4 { + t.Errorf("visibleWidth = %d, want 4", got) + } + if got := visibleWidth("plain"); got != 5 { + t.Errorf("visibleWidth(plain) = %d, want 5", got) + } +} diff --git a/internal/tui/progress_tracker.go b/internal/tui/progress_tracker.go new file mode 100644 index 000000000..e92014f0e --- /dev/null +++ b/internal/tui/progress_tracker.go @@ -0,0 +1,299 @@ +package tui + +import ( + "fmt" + "strings" + "sync" + + "github.com/fatih/color" +) + +// StepState mirrors Node's StepStatus enum (src/lib/cli/progress.ts:8). +type StepState string + +const ( + StepPending StepState = "pending" + StepRunning StepState = "running" + StepSuccess StepState = "success" + StepFailed StepState = "failed" + StepUnknown StepState = "unknown" + StepSkipped StepState = "skipped" +) + +// SpinnerGlyphs is Node's RUNNING_SPRITE_GLYPHS (src/lib/cli/format.ts:152). +var SpinnerGlyphs = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// GlyphForStatus mirrors Node format.ts getGlyphForStatus (format.ts:169). +// spinner is the current spinner glyph used for "running". +func GlyphForStatus(s StepState, spinner string) string { + switch s { + case StepPending: + return "○" + case StepRunning: + return color.HiBlueString(spinner) + case StepSuccess: + return color.GreenString("✓") + case StepFailed: + return color.RedString("✕") + case StepUnknown: + return color.YellowString("✕") + case StepSkipped: + return color.GreenString("-") + default: + return "" + } +} + +// ProgressStep seeds a caller-defined step. +type ProgressStep struct { + ID string + Name string +} + +// ServerStep is a server-reported step (Node's StepFromServer, +// progress.ts:30). +type ServerStep struct { + Name string + Status StepState +} + +type trackedStep struct { + id string + name string + status StepState + percentage string // upload step only (progress.ts:83) + progress string // generic per-step progress line (progress.ts:91) + additionalInfo []string // bullet lines under the step +} + +// ProgressTracker ports Node's ProgressTracker (src/lib/cli/progress.ts:35). +// Caller-defined steps render first, then server-reported steps — Node +// merges the two maps in that order (progress.ts:72). +// +// Safe for concurrent use: the upload progress callback fires from worker +// goroutines while a render ticker reads Frame(). +type ProgressTracker struct { + mu sync.Mutex + fromCaller []*trackedStep + fromServer []*trackedStep + spinnerIdx int + hasFailure bool + prefix string + suffix string +} + +// NewProgressTracker builds a tracker with the given caller-defined steps, +// all starting pending (progress.ts:76 mapSteps default). +func NewProgressTracker(steps []ProgressStep) *ProgressTracker { + pt := &ProgressTracker{} + for _, s := range steps { + pt.fromCaller = append(pt.fromCaller, &trackedStep{ + id: s.ID, name: s.Name, status: StepPending, + }) + } + return pt +} + +// SetPrefix sets the text printed before the step list (progress.ts:48). +func (pt *ProgressTracker) SetPrefix(p string) { + pt.mu.Lock() + defer pt.mu.Unlock() + pt.prefix = p +} + +// SetSuffix sets the text printed after the step list (progress.ts:51). +func (pt *ProgressTracker) SetSuffix(s string) { + pt.mu.Lock() + defer pt.mu.Unlock() + pt.suffix = s +} + +func (pt *ProgressTracker) find(id string) *trackedStep { + for _, s := range pt.fromCaller { + if s.id == id { + return s + } + } + return nil +} + +// setStatus mirrors setStatusForStepId (progress.ts:163). Completed steps +// (success/skipped — COMPLETED_STEP_SLUGS, progress.ts:17) reject further +// updates. Error strings are Node's exact messages. +func (pt *ProgressTracker) setStatus(id string, status StepState, info []string) error { + pt.mu.Lock() + defer pt.mu.Unlock() + s := pt.find(id) + if s == nil { + return fmt.Errorf("Step name %s is not valid.", id) + } + if s.status == StepSuccess || s.status == StepSkipped { + return fmt.Errorf("Step name %s is already completed.", id) + } + if status == StepFailed { + pt.hasFailure = true + } + s.status = status + s.additionalInfo = info + return nil +} + +func (pt *ProgressTracker) StepRunning(id string, info ...string) error { + return pt.setStatus(id, StepRunning, info) +} + +func (pt *ProgressTracker) StepFailed(id string, info ...string) error { + return pt.setStatus(id, StepFailed, info) +} + +func (pt *ProgressTracker) StepSkipped(id string, info ...string) error { + return pt.setStatus(id, StepSkipped, info) +} + +// StepSuccess marks id success and auto-promotes the next pending step to +// running (progress.ts:150). +func (pt *ProgressTracker) StepSuccess(id string, info ...string) error { + if err := pt.setStatus(id, StepSuccess, info); err != nil { + return err + } + pt.mu.Lock() + defer pt.mu.Unlock() + for _, s := range pt.all() { + if s.status == StepPending { + s.status = StepRunning + break + } + } + return nil +} + +// SetUploadPercentage stores the percentage shown next to the "upload" +// step while it is running (progress.ts:83 setUploadPercentage). +func (pt *ProgressTracker) SetUploadPercentage(p string) { + pt.mu.Lock() + defer pt.mu.Unlock() + if s := pt.find("upload"); s != nil { + s.percentage = p + } +} + +// SetProgress stores a free-form progress string on the CURRENT running +// step (progress.ts:91 setProgress via getCurrentStep). No-op when no +// step is running. +func (pt *ProgressTracker) SetProgress(p string) { + pt.mu.Lock() + defer pt.mu.Unlock() + for _, s := range pt.all() { + if s.status == StepRunning { + s.progress = p + return + } + } +} + +// SetStepsFromServer replaces the server-step list. If no server step is +// running, the first pending one is promoted to running (progress.ts:100 +// setStepsFromServer). +func (pt *ProgressTracker) SetStepsFromServer(steps []ServerStep) { + pt.mu.Lock() + defer pt.mu.Unlock() + anyRunning := false + for _, s := range steps { + if s.Status == StepRunning { + anyRunning = true + break + } + } + out := make([]*trackedStep, 0, len(steps)) + promoted := false + for i, s := range steps { + st := s.Status + if !anyRunning && !promoted && st == StepPending { + st = StepRunning + promoted = true + } + out = append(out, &trackedStep{ + id: fmt.Sprintf("server-%d-%s", i, s.Name), + name: s.Name, + status: st, + }) + } + pt.fromServer = out +} + +// all returns caller steps followed by server steps. Caller must hold mu. +func (pt *ProgressTracker) all() []*trackedStep { + merged := make([]*trackedStep, 0, len(pt.fromCaller)+len(pt.fromServer)) + merged = append(merged, pt.fromCaller...) + merged = append(merged, pt.fromServer...) + return merged +} + +// AllStepsSucceeded mirrors allStepsSucceeded (progress.ts:159): every +// step (caller + server) must be success. +func (pt *ProgressTracker) AllStepsSucceeded() bool { + pt.mu.Lock() + defer pt.mu.Unlock() + for _, s := range pt.all() { + if s.status != StepSuccess { + return false + } + } + return true +} + +func (pt *ProgressTracker) HasFailure() bool { + pt.mu.Lock() + defer pt.mu.Unlock() + return pt.hasFailure +} + +// CurrentStepID returns the id of the first running step ("" if none). +func (pt *ProgressTracker) CurrentStepID() string { + pt.mu.Lock() + defer pt.mu.Unlock() + for _, s := range pt.all() { + if s.status == StepRunning { + return s.id + } + } + return "" +} + +// Frame renders the current state as a multi-line block, one line per +// step (Node progress.ts:252 print()). Line shape is +// " \n" — note the trailing space before an empty +// suffix, matching Node's `${statusIcon} ${name} ${suffix}\n`. The +// spinner advances one glyph per Frame call, mirroring +// RunningSprite.toString()'s advance-on-read (format.ts:160). +func (pt *ProgressTracker) Frame() string { + pt.mu.Lock() + defer pt.mu.Unlock() + spinner := SpinnerGlyphs[pt.spinnerIdx] + pt.spinnerIdx = (pt.spinnerIdx + 1) % len(SpinnerGlyphs) + + var b strings.Builder + b.WriteString(pt.prefix) + for _, s := range pt.all() { + suffix := "" + if s.id == "upload" { + if s.status == StepRunning && s.percentage != "" { + suffix = s.percentage + } + } else if s.progress != "" { + // progress.ts:270 — non-upload steps render their progress + // string whenever set, regardless of status. + suffix = s.progress + } + if len(s.additionalInfo) > 0 { + var infoLines []string + for _, info := range s.additionalInfo { + infoLines = append(infoLines, " - "+info) + } + suffix += "\n" + strings.Join(infoLines, "\n") + } + fmt.Fprintf(&b, "%s %s %s\n", GlyphForStatus(s.status, spinner), s.name, suffix) + } + b.WriteString(pt.suffix) + return b.String() +} diff --git a/internal/tui/progress_tracker_test.go b/internal/tui/progress_tracker_test.go new file mode 100644 index 000000000..1de3c4c7c --- /dev/null +++ b/internal/tui/progress_tracker_test.go @@ -0,0 +1,155 @@ +package tui + +import ( + "strings" + "testing" +) + +func steps3() []ProgressStep { + return []ProgressStep{ + {ID: "replace", Name: "Performing search and replace"}, + {ID: "upload", Name: "Uploading file"}, + {ID: "queue_import", Name: "Queueing import"}, + } +} + +func TestProgressTrackerFrameOrderAndGlyphs(t *testing.T) { + pt := NewProgressTracker(steps3()) + if err := pt.StepRunning("replace"); err != nil { + t.Fatal(err) + } + frame := pt.Frame() + lines := strings.Split(strings.TrimRight(frame, "\n"), "\n") + if len(lines) != 3 { + t.Fatalf("want 3 lines, got %d: %q", len(lines), frame) + } + if !strings.Contains(lines[0], "Performing search and replace") { + t.Errorf("line 0 = %q", lines[0]) + } + // pending glyph is ○ (Node format.ts getGlyphForStatus) + if !strings.Contains(lines[1], "○") { + t.Errorf("pending glyph missing: %q", lines[1]) + } +} + +func TestProgressTrackerStepSuccessPromotesNext(t *testing.T) { + pt := NewProgressTracker(steps3()) + _ = pt.StepRunning("replace") + if err := pt.StepSuccess("replace"); err != nil { + t.Fatal(err) + } + // Node progress.ts:150 — stepSuccess auto-promotes next pending to running. + if got := pt.CurrentStepID(); got != "upload" { + t.Errorf("current step = %q, want upload", got) + } +} + +func TestProgressTrackerCompletedStepRejected(t *testing.T) { + pt := NewProgressTracker(steps3()) + _ = pt.StepSuccess("replace") + err := pt.StepRunning("replace") + if err == nil || !strings.Contains(err.Error(), "already completed") { + t.Errorf("want already-completed error, got %v", err) + } + if err := pt.StepRunning("nope"); err == nil || + !strings.Contains(err.Error(), "is not valid") { + t.Errorf("want invalid-step error, got %v", err) + } +} + +func TestProgressTrackerSkippedStepRejectsUpdates(t *testing.T) { + pt := NewProgressTracker(steps3()) + _ = pt.StepSkipped("replace") + if err := pt.StepRunning("replace"); err == nil || + !strings.Contains(err.Error(), "already completed") { + t.Errorf("skipped step must reject updates (Node COMPLETED_STEP_SLUGS), got %v", err) + } +} + +func TestProgressTrackerUploadPercentageSuffix(t *testing.T) { + pt := NewProgressTracker(steps3()) + _ = pt.StepRunning("upload") + pt.SetUploadPercentage("42%") + if frame := pt.Frame(); !strings.Contains(frame, "42%") { + t.Errorf("frame missing percentage: %q", frame) + } + // percentage only renders while running (progress.ts:266-268) + _ = pt.StepSuccess("upload") + if frame := pt.Frame(); strings.Contains(frame, "42%") { + t.Errorf("percentage must not render after success: %q", frame) + } +} + +func TestProgressTrackerServerStepsPromoteFirstPending(t *testing.T) { + pt := NewProgressTracker(nil) + pt.SetStepsFromServer([]ServerStep{ + {Name: "Import preflights", Status: StepSuccess}, + {Name: "Importing db", Status: StepPending}, + }) + // Node progress.ts:107 — no running step => first pending promoted. + frame := pt.Frame() + if !strings.Contains(frame, "Importing db") { + t.Fatalf("frame = %q", frame) + } + if pt.AllStepsSucceeded() { + t.Error("AllStepsSucceeded should be false with a pending step") + } + + pt.SetStepsFromServer([]ServerStep{ + {Name: "Import preflights", Status: StepSuccess}, + {Name: "Importing db", Status: StepSuccess}, + }) + if !pt.AllStepsSucceeded() { + t.Error("AllStepsSucceeded should be true when every step succeeded") + } +} + +func TestProgressTrackerHasFailure(t *testing.T) { + pt := NewProgressTracker(steps3()) + _ = pt.StepFailed("upload") + if !pt.HasFailure() { + t.Error("HasFailure should be true") + } +} + +func TestProgressTrackerAdditionalInfoBullets(t *testing.T) { + pt := NewProgressTracker(steps3()) + _ = pt.StepFailed("upload", "first detail", "second detail") + frame := pt.Frame() + if !strings.Contains(frame, " - first detail") || !strings.Contains(frame, " - second detail") { + t.Errorf("additionalInfo bullets missing: %q", frame) + } +} + +func TestProgressTrackerSetProgressOnRunningStep(t *testing.T) { + pt := NewProgressTracker([]ProgressStep{{ID: "download", Name: "Downloading file"}}) + _ = pt.StepRunning("download") + pt.SetProgress("- 42.00% (10 MB/24 MB)") + if frame := pt.Frame(); !strings.Contains(frame, "- 42.00% (10 MB/24 MB)") { + t.Errorf("frame = %q", frame) + } + // progress renders on non-upload steps regardless of status once set + // (progress.ts:270 `else if (progress)`). + _ = pt.StepSuccess("download") + if frame := pt.Frame(); !strings.Contains(frame, "42.00%") { + t.Errorf("progress must persist after success: %q", frame) + } +} + +func TestProgressTrackerSetProgressNoRunningStepIsNoop(t *testing.T) { + pt := NewProgressTracker(steps3()) + pt.SetProgress("- 10%") + if frame := pt.Frame(); strings.Contains(frame, "- 10%") { + t.Errorf("SetProgress without a running step must be a no-op (progress.ts:92): %q", frame) + } +} + +func TestProgressTrackerPrefixSuffix(t *testing.T) { + pt := NewProgressTracker(steps3()) + pt.SetPrefix("HEAD\n") + pt.SetSuffix("\nTAIL") + frame := pt.Frame() + if !strings.HasPrefix(frame, "HEAD\n") || !strings.HasSuffix(frame, "\nTAIL") { + t.Errorf("prefix/suffix not rendered: %q", frame) + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 000000000..0102a3fdb --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,14 @@ +// Package version exposes the binary version metadata. +// Values are injected via -ldflags at build time (see Makefile). +package version + +import "fmt" + +var ( + Version = "dev" + Commit = "unknown" +) + +func String() string { + return fmt.Sprintf("vip-next %s (commit %s)", Version, Commit) +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 000000000..8d1093a25 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,25 @@ +package version + +import "testing" + +func TestStringIncludesVersionAndCommit(t *testing.T) { + Version = "1.2.3" + Commit = "abcdef0" + + got := String() + want := "vip-next 1.2.3 (commit abcdef0)" + if got != want { + t.Errorf("String() = %q, want %q", got, want) + } +} + +func TestStringDefaultWhenUnset(t *testing.T) { + Version = "dev" + Commit = "unknown" + + got := String() + want := "vip-next dev (commit unknown)" + if got != want { + t.Errorf("String() = %q, want %q", got, want) + } +} From b7f848e395c041dba64660565f75de388d8fa7f1 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 20 Aug 2026 18:36:31 -0500 Subject: [PATCH 04/32] feat(go): GraphQL schema and operations The schema and the 19 .graphql operation documents are the codegen inputs; the generated bindings land in the next commit so they can be reviewed separately, or skipped. Ported from vip-cli-golang@72ef2f89 (feature/go-rewrite). --- internal/gql/SCHEMA.md | 21 + internal/gql/genqlient.yaml | 20 + internal/gql/operations/app_get.graphql | 43 + internal/gql/operations/app_list.graphql | 9 + internal/gql/operations/app_resolve.graphql | 37 + .../operations/backup_export_deploy.graphql | 123 + internal/gql/operations/cachepurge.graphql | 6 + .../gql/operations/defensive_mode.graphql | 13 + .../gql/operations/dev_env_create.graphql | 38 + internal/gql/operations/dev_env_sync.graphql | 15 + internal/gql/operations/envvar.graphql | 52 + internal/gql/operations/fragments.graphql | 5 + internal/gql/operations/import_media.graphql | 61 + internal/gql/operations/import_sql.graphql | 135 + internal/gql/operations/logs.graphql | 22 + internal/gql/operations/me.graphql | 13 + internal/gql/operations/phpmyadmin.graphql | 21 + internal/gql/operations/slowlogs.graphql | 25 + internal/gql/operations/software.graphql | 53 + internal/gql/operations/sync.graphql | 51 + internal/gql/operations/wp.graphql | 37 + internal/gql/schema.gql | 9311 +++++++++++++++++ 22 files changed, 10111 insertions(+) create mode 100644 internal/gql/SCHEMA.md create mode 100644 internal/gql/genqlient.yaml create mode 100644 internal/gql/operations/app_get.graphql create mode 100644 internal/gql/operations/app_list.graphql create mode 100644 internal/gql/operations/app_resolve.graphql create mode 100644 internal/gql/operations/backup_export_deploy.graphql create mode 100644 internal/gql/operations/cachepurge.graphql create mode 100644 internal/gql/operations/defensive_mode.graphql create mode 100644 internal/gql/operations/dev_env_create.graphql create mode 100644 internal/gql/operations/dev_env_sync.graphql create mode 100644 internal/gql/operations/envvar.graphql create mode 100644 internal/gql/operations/fragments.graphql create mode 100644 internal/gql/operations/import_media.graphql create mode 100644 internal/gql/operations/import_sql.graphql create mode 100644 internal/gql/operations/logs.graphql create mode 100644 internal/gql/operations/me.graphql create mode 100644 internal/gql/operations/phpmyadmin.graphql create mode 100644 internal/gql/operations/slowlogs.graphql create mode 100644 internal/gql/operations/software.graphql create mode 100644 internal/gql/operations/sync.graphql create mode 100644 internal/gql/operations/wp.graphql create mode 100644 internal/gql/schema.gql diff --git a/internal/gql/SCHEMA.md b/internal/gql/SCHEMA.md new file mode 100644 index 000000000..2884990bf --- /dev/null +++ b/internal/gql/SCHEMA.md @@ -0,0 +1,21 @@ +# GraphQL schema vendoring + +`internal/gql/schema.gql` is vendored from the Node project's `schema.gql`, +which is itself generated by `npm run typescript:codegen:generate` against +the live VIP GraphQL API. + +## Refreshing the schema + +1. From the repo root: `npm install` and run `npm run typescript:codegen:generate`. + This requires a valid Node-CLI token (`vip login` if needed). +2. Copy the generated `schema.gql` to `internal/gql/schema.gql`. +3. Run `go generate ./internal/gql/...` to regenerate the typed client. +4. Run the full test suite + parity harness. +5. Commit the new `schema.gql` and the regenerated `generated.go` in one commit + with subject `chore(gql): refresh vendored schema and regenerated client`. + +## Why we vendor + +The schema is the source of truth for typed query generation. The Node project +generates it on demand and gitignores the result. The Go project pins a copy +so deterministic builds don't depend on having a live API endpoint or token. diff --git a/internal/gql/genqlient.yaml b/internal/gql/genqlient.yaml new file mode 100644 index 000000000..e0ee0d22d --- /dev/null +++ b/internal/gql/genqlient.yaml @@ -0,0 +1,20 @@ +schema: schema.gql +operations: + - operations/*.graphql +generated: generated.go +package: gql +use_struct_references: true +optional: pointer +bindings: + Int: + type: int64 + ID: + type: string + BigInt: + type: int64 + JSON: + type: encoding/json.RawMessage + # Free-form {ext: type-label} map returned by mediaImportConfig + # (media-import/config.ts) — same raw-JSON treatment as JSON. + MediaImportAllowedFileTypes: + type: encoding/json.RawMessage diff --git a/internal/gql/operations/app_get.graphql b/internal/gql/operations/app_get.graphql new file mode 100644 index 000000000..1260cf8e0 --- /dev/null +++ b/internal/gql/operations/app_get.graphql @@ -0,0 +1,43 @@ +query AppGetByName($name: String!) { + apps(first: 1, name: $name) { + edges { + id + name + repo + environments { + id + appId + name + type + branch + currentCommit + primaryDomain { + name + } + launched + deploymentStrategy + } + } + } +} + +query AppGetByID($id: Int!) { + app(id: $id) { + id + name + repo + environments { + id + appId + name + type + branch + currentCommit + primaryDomain { + name + } + launched + deploymentStrategy + } + } +} diff --git a/internal/gql/operations/app_list.graphql b/internal/gql/operations/app_list.graphql new file mode 100644 index 000000000..3c50f8472 --- /dev/null +++ b/internal/gql/operations/app_list.graphql @@ -0,0 +1,9 @@ +query AppList($first: Int, $after: String) { + apps(first: $first, after: $after) { + total + nextCursor + edges { + ...AppBasic + } + } +} diff --git a/internal/gql/operations/app_resolve.graphql b/internal/gql/operations/app_resolve.graphql new file mode 100644 index 000000000..d78909e7a --- /dev/null +++ b/internal/gql/operations/app_resolve.graphql @@ -0,0 +1,37 @@ +query ResolveAppByName($name: String!) { + apps(first: 1, name: $name) { + edges { + id + name + type + typeId + environments { + id + appId + name + type + uniqueLabel + defaultDomain + isMultisite + } + } + } +} + +query ResolveAppByID($id: Int!) { + app(id: $id) { + id + name + type + typeId + environments { + id + appId + name + type + uniqueLabel + defaultDomain + isMultisite + } + } +} diff --git a/internal/gql/operations/backup_export_deploy.graphql b/internal/gql/operations/backup_export_deploy.graphql new file mode 100644 index 000000000..039d02958 --- /dev/null +++ b/internal/gql/operations/backup_export_deploy.graphql @@ -0,0 +1,123 @@ +# Backup / export / deploy operations. Node sources: +# TriggerDatabaseBackup / AppBackupJobStatus — src/commands/backup-db.ts:20,28 +# AppBackupAndJobStatus — src/commands/export-sql.ts:36 +# GenerateDBBackupCopyUrl / BackupDBCopy — src/commands/export-sql.ts:77,87 +# StartLiveBackupCopy / download URL — src/lib/live-backup-copy.ts:85,95 +# StartCustomDeploy — src/bin/vip-app-deploy.ts:29 +# ValidateCustomDeployAccess — src/lib/custom-deploy/custom-deploy.ts:36 + +mutation TriggerDatabaseBackup($input: AppEnvironmentTriggerDBBackupInput) { + triggerDatabaseBackup(input: $input) { + success + } +} + +query AppBackupJobStatus($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + jobs(jobTypes: [db_backup]) { + id + type + completedAt + createdAt + inProgressLock + metadata { + name + value + } + progress { + status + } + } + } + } +} + +query AppBackupAndJobStatus($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + backupsSqlDumpTool + latestBackup { + id + type + size + filename + sqlDumpTool + createdAt + } + jobs(jobTypes: [db_backup_copy]) { + id + type + completedAt + createdAt + inProgressLock + metadata { + name + value + } + progress { + status + steps { + id + name + step + status + } + } + } + } + } +} + +mutation GenerateDBBackupCopyUrl($input: AppEnvironmentGenerateDBBackupCopyUrlInput) { + generateDBBackupCopyUrl(input: $input) { + url + success + } +} + +mutation BackupDBCopy($input: AppEnvironmentStartDBBackupCopyInput) { + startDBBackupCopy(input: $input) { + message + success + } +} + +mutation StartLiveBackupCopy($input: LiveBackupCopyConfigInput!) { + startLiveBackupCopy(input: $input) { + message + copyId + } +} + +mutation GenerateLiveBackupCopyDownloadURL($input: AppEnvironmentLiveBackupCopyDownloadURLInput!) { + generateLiveBackupCopyDownloadURL(input: $input) { + success + url + processing + size + } +} + +mutation StartCustomDeploy($input: AppEnvironmentCustomDeployInput) { + startCustomDeploy(input: $input) { + success + message + } +} + +mutation ValidateCustomDeployAccess($input: ValidateCustomDeployAccessInput!) { + validateCustomDeployAccess(input: $input) { + success + appId + envId + envType + envUniqueLabel + primaryDomainName + launched + } +} diff --git a/internal/gql/operations/cachepurge.graphql b/internal/gql/operations/cachepurge.graphql new file mode 100644 index 000000000..a72f8a15e --- /dev/null +++ b/internal/gql/operations/cachepurge.graphql @@ -0,0 +1,6 @@ +mutation PurgePageCache($input: PurgePageCacheInput!) { + purgePageCache(input: $input) { + success + urls + } +} diff --git a/internal/gql/operations/defensive_mode.graphql b/internal/gql/operations/defensive_mode.graphql new file mode 100644 index 000000000..932840298 --- /dev/null +++ b/internal/gql/operations/defensive_mode.graphql @@ -0,0 +1,13 @@ +mutation UpdateDefensiveModeStatus($input: AppEnvironmentDefensiveModeUpdateStatusInput!) { + updateDefensiveModeStatus(input: $input) { + success + message + } +} + +mutation UpdateDefensiveModeConfig($input: AppEnvironmentDefensiveModeConfigInput!) { + updateDefensiveModeConfig(input: $input) { + success + message + } +} diff --git a/internal/gql/operations/dev_env_create.graphql b/internal/gql/operations/dev_env_create.graphql new file mode 100644 index 000000000..1d618d85e --- /dev/null +++ b/internal/gql/operations/dev_env_create.graphql @@ -0,0 +1,38 @@ +# dev-env create @app.env pre-population. Node source: +# getApplicationInformation — src/lib/dev-environment/dev-environment-core.ts:735 +# getOptionsFromAppInfo — src/lib/dev-environment/dev-environment-cli.ts:257 +# Fetches all environments (no useful server-side filter; the env is picked +# client-side by type) with the fields that seed the wizard defaults. +query DevEnvAppInfo($appId: Int!) { + app(id: $appId) { + id + name + environments { + id + appId + name + type + isMultisite + primaryDomain { + name + } + environmentVariables { + nodes { + name + } + } + softwareSettings { + php { + current { + version + } + } + wordpress { + current { + version + } + } + } + } + } +} diff --git a/internal/gql/operations/dev_env_sync.graphql b/internal/gql/operations/dev_env_sync.graphql new file mode 100644 index 000000000..14caadc61 --- /dev/null +++ b/internal/gql/operations/dev_env_sync.graphql @@ -0,0 +1,15 @@ +query DevEnvSyncSites($appId: Int!, $environmentId: Int!, $after: String, $first: Int!) { + app(id: $appId) { + environments(id: $environmentId) { + wpSitesSDS(after: $after, first: $first) { + total + nextCursor + nodes { + blogId + homeUrl + siteUrl + } + } + } + } +} diff --git a/internal/gql/operations/envvar.graphql b/internal/gql/operations/envvar.graphql new file mode 100644 index 000000000..1d87ebb96 --- /dev/null +++ b/internal/gql/operations/envvar.graphql @@ -0,0 +1,52 @@ +query GetEnvironmentVariables($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + environmentVariables { + total + nodes { + name + } + } + } + } +} + +query GetEnvironmentVariablesWithValues($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + environmentVariables { + total + nodes { + name + value + } + } + } + } +} + +mutation AddEnvironmentVariable($input: EnvironmentVariableInput!) { + addEnvironmentVariable(input: $input) { + environmentVariables { + total + nodes { + name + } + } + } +} + +mutation DeleteEnvironmentVariable($input: EnvironmentVariableInput!) { + deleteEnvironmentVariable(input: $input) { + environmentVariables { + total + nodes { + name + } + } + } +} diff --git a/internal/gql/operations/fragments.graphql b/internal/gql/operations/fragments.graphql new file mode 100644 index 000000000..64d37e952 --- /dev/null +++ b/internal/gql/operations/fragments.graphql @@ -0,0 +1,5 @@ +fragment AppBasic on App { + id + name + repo +} diff --git a/internal/gql/operations/import_media.graphql b/internal/gql/operations/import_media.graphql new file mode 100644 index 000000000..ad934e0fc --- /dev/null +++ b/internal/gql/operations/import_media.graphql @@ -0,0 +1,61 @@ +# Media-import operations. Node sources: +# StartMediaImport — src/bin/vip-import-media.js:37 +# AbortMediaImport — src/bin/vip-import-media-abort.js:33 +# progress query — src/lib/media-import/status.ts:28 +# MediaImportConfig — src/lib/media-import/config.ts:8 + +mutation StartMediaImport($input: AppEnvironmentStartMediaImportInput) { + startMediaImport(input: $input) { + applicationId + environmentId + mediaImportStatus { + importId + siteId + status + } + } +} + +mutation AbortMediaImport($input: AppEnvironmentAbortMediaImportInput) { + abortMediaImport(input: $input) { + applicationId + environmentId + mediaImportStatusChange { + importId + siteId + statusFrom + statusTo + } + } +} + +query MediaImportProgress($appId: Int, $envId: Int) { + app(id: $appId) { + environments(id: $envId) { + id + name + type + repo + mediaImportStatus { + importId + siteId + status + filesTotal + filesProcessed + failureDetails { + previousStatus + globalErrors + fileErrorsUrl + } + } + } + } +} + +query MediaImportConfig { + mediaImportConfig { + fileNameCharCount + fileSizeLimitInBytes + allowedFileTypes + } +} diff --git a/internal/gql/operations/import_sql.graphql b/internal/gql/operations/import_sql.graphql new file mode 100644 index 000000000..f158cff99 --- /dev/null +++ b/internal/gql/operations/import_sql.graphql @@ -0,0 +1,135 @@ +# Import-sql operations. Node sources: +# appQuery — src/bin/vip-import-sql.js:41 +# StartImport — src/bin/vip-import-sql.js:69 +# AppMultiSiteCheck — src/lib/validations/is-multi-site.ts:27 +# AppMappedDomains — src/lib/validations/is-multisite-domain-mapped.ts:82 +# App (import status) — src/lib/site-import/status.ts:27 + +query ImportSQLEnvInfo($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + name + typeId + environments(id: $envId) { + id + appId + type + name + launched + isK8sResident + primaryDomain { + name + } + importStatus { + dbOperationInProgress + importInProgress + } + wpSitesSDS { + nodes { + homeUrl + id + } + } + } + } +} + +# The startImport server resolver calls input.searchReplace.filter(...) and +# expects urlHeaders to be present, so empty arrays must be sent as [] rather +# than omitted. Disable genqlient's default omitempty on these list fields to +# match the Node CLI (which always sends searchReplace: []). The $input variable +# is on its own line so the for-directives attach to the operation, not $input. +# @genqlient(for: "AppEnvironmentImportInput.searchReplace", omitempty: false) +# @genqlient(for: "AppEnvironmentImportInput.urlHeaders", omitempty: false) +# +# `--search-replace="a"` (no comma) leaves arr[1] undefined in Node +# (vip-import-sql.js:821-827), and JSON.stringify drops undefined properties, +# so the pair goes over the wire as {from:"a"} with NO `to` key. Sending +# to:"" instead means "replace every occurrence of a with nothing" — silent +# data destruction. omitempty lets a nil *string reproduce Node's omission; +# a non-nil pointer to "" (from a trailing comma, "a,") still serializes. +# @genqlient(for: "AppEnvironmentImportSearchReplace.to", omitempty: true) +mutation StartImport( + $input: AppEnvironmentImportInput +) { + startImport(input: $input) { + app { + id + name + } + message + success + } +} + +query AppMultiSiteCheck($appId: Int, $envId: Int) { + app(id: $appId) { + id + name + repo + environments(id: $envId) { + id + appId + name + type + isMultisite + isSubdirectoryMultisite + } + } +} + +query AppMappedDomains($appId: Int, $envId: Int) { + app(id: $appId) { + id + name + environments(id: $envId) { + uniqueLabel + isMultisite + domains { + nodes { + name + isPrimary + } + } + } + } +} + +query ImportSQLProgress($appId: Int, $envId: Int) { + app(id: $appId) { + environments(id: $envId) { + id + isK8sResident + launched + jobs(types: ["sql_import"]) { + id + type + completedAt + createdAt + progress { + status + steps { + id + name + status + } + } + } + importStatus { + dbOperationInProgress + importInProgress + progress { + started_at + steps { + name + started_at + finished_at + result + output + } + finished_at + } + } + } + } +} diff --git a/internal/gql/operations/logs.graphql b/internal/gql/operations/logs.graphql new file mode 100644 index 000000000..8d2aa64ab --- /dev/null +++ b/internal/gql/operations/logs.graphql @@ -0,0 +1,22 @@ +query GetAppLogs( + $appId: Int! + $envId: Int! + $logType: AppEnvironmentLogType! + $limit: Int! + $after: String +) { + app(id: $appId) { + id + environments(id: $envId) { + id + logs(type: $logType, limit: $limit, after: $after) { + nodes { + timestamp + message + } + nextCursor + pollingDelaySeconds + } + } + } +} diff --git a/internal/gql/operations/me.graphql b/internal/gql/operations/me.graphql new file mode 100644 index 000000000..dfe100ebc --- /dev/null +++ b/internal/gql/operations/me.graphql @@ -0,0 +1,13 @@ +query Me { + me { + id + displayName + isVIP + organizationRoles { + nodes { + organizationId + roleId + } + } + } +} diff --git a/internal/gql/operations/phpmyadmin.graphql b/internal/gql/operations/phpmyadmin.graphql new file mode 100644 index 000000000..b7f5921be --- /dev/null +++ b/internal/gql/operations/phpmyadmin.graphql @@ -0,0 +1,21 @@ +mutation EnablePhpMyAdmin($input: EnablePhpMyAdminInput!) { + enablePHPMyAdmin(input: $input) { + success + } +} + +query PhpMyAdminStatus($appId: Int!, $envId: Int!) { + app(id: $appId) { + environments(id: $envId) { + phpMyAdminStatus { + status + } + } + } +} + +mutation GeneratePhpMyAdminAccess($input: GeneratePhpMyAdminAccessInput!) { + generatePHPMyAdminAccess(input: $input) { + url + } +} diff --git a/internal/gql/operations/slowlogs.graphql b/internal/gql/operations/slowlogs.graphql new file mode 100644 index 000000000..a09f36c31 --- /dev/null +++ b/internal/gql/operations/slowlogs.graphql @@ -0,0 +1,25 @@ +query GetAppSlowlogs( + $appId: Int! + $envId: Int! + $limit: Int! + $after: String +) { + app(id: $appId) { + id + environments(id: $envId) { + id + slowlogs(limit: $limit, after: $after) { + nodes { + timestamp + rowsSent + rowsExamined + queryTime + requestUri + query + } + nextCursor + pollingDelaySeconds + } + } + } +} diff --git a/internal/gql/operations/software.graphql b/internal/gql/operations/software.graphql new file mode 100644 index 000000000..b8eadcaef --- /dev/null +++ b/internal/gql/operations/software.graphql @@ -0,0 +1,53 @@ +# vip config software operations. Node source: src/lib/config/software.ts +# (appQuery/appQueryFragments, updateSoftwareMutation, updateJobQuery). + +fragment SoftwareNode on AppEnvironmentSoftwareSettingsSoftware { + name + slug + pinned + current { version default deprecated unstable compatible latestRelease private } + options { version default deprecated unstable compatible latestRelease private } +} + +query SoftwareSettings($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + name + typeId + environments(id: $envId) { + id + appId + type + name + softwareSettings { + wordpress { ...SoftwareNode } + php { ...SoftwareNode } + muplugins { ...SoftwareNode } + nodejs { ...SoftwareNode } + } + } + } +} + +mutation UpdateSoftwareSettings($appId: Int!, $envId: Int!, $component: String!, $version: String!) { + updateSoftwareSettings(input: {appId: $appId, environmentId: $envId, softwareName: $component, softwareVersion: $version}) { + wordpress { ...SoftwareNode } + php { ...SoftwareNode } + muplugins { ...SoftwareNode } + nodejs { ...SoftwareNode } + } +} + +query SoftwareUpdateJob($appId: Int!, $envId: Int!) { + app(id: $appId) { + environments(id: $envId) { + jobs(types: ["upgrade_php", "upgrade_wordpress", "upgrade_muplugins", "upgrade_nodejs"]) { + type + completedAt + createdAt + inProgressLock + progress { status steps { step name status } } + } + } + } +} diff --git a/internal/gql/operations/sync.graphql b/internal/gql/operations/sync.graphql new file mode 100644 index 000000000..b6eaa6be5 --- /dev/null +++ b/internal/gql/operations/sync.graphql @@ -0,0 +1,51 @@ +mutation SyncEnvironment($input: AppEnvironmentSyncInput!) { + syncEnvironment(input: $input) { + environment { + id + } + } +} + +# The pre-flight Node runs before the sync mutation. Node folds these +# fields into vip-sync.js's appQuery; vip-next resolves app/env through a +# shared query, so the preview is fetched separately by the confirmation +# payload (src/lib/cli/command.js:913-933). +query SyncPreview($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + syncPreview { + canSync + errors { + message + } + backup { + createdAt + } + replacements { + from + to + } + } + } + } +} + +query SyncProgress($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + syncProgress { + status + sync + steps { + name + status + step + } + } + } + } +} diff --git a/internal/gql/operations/wp.graphql b/internal/gql/operations/wp.graphql new file mode 100644 index 000000000..bf9c0b41d --- /dev/null +++ b/internal/gql/operations/wp.graphql @@ -0,0 +1,37 @@ +# vip wp operations. Node sources: +# TriggerWPCLICommand — src/bin/vip-wp.js:127 / src/commands/wp-ssh.ts:41 +# WPEnvInfo (wpcliStrategy + primaryDomain + typeId) — src/bin/vip-wp.js:26 + +mutation TriggerWPCLICommand($input: AppEnvironmentTriggerWPCLICommandInput) { + triggerWPCLICommandOnAppEnvironment(input: $input) { + inputToken + command { + guid + } + sshAuthentication { + host + port + username + privateKey + passphrase + } + } +} + +query WPEnvInfo($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + name + typeId + environments(id: $envId) { + id + appId + type + name + wpcliStrategy + primaryDomain { + name + } + } + } +} diff --git a/internal/gql/schema.gql b/internal/gql/schema.gql new file mode 100644 index 000000000..723e7270a --- /dev/null +++ b/internal/gql/schema.gql @@ -0,0 +1,9311 @@ +"""Controls the rate of traffic.""" +directive @rateLimit( + """Number of occurrences allowed over duration.""" + limit: Int! = 60 + + """Number of seconds before limit is reset.""" + duration: Int! = 60 +) on OBJECT | FIELD_DEFINITION + +"""Controls the rate of traffic.""" +directive @rateLimitPerModel( + """Number of occurrences allowed over duration.""" + limit: Int! = 60 + + """Number of seconds before limit is reset.""" + duration: Int! = 60 +) on OBJECT | FIELD_DEFINITION + +"""Controls the rate of traffic.""" +directive @rateLimitPerModelAndUser( + """Number of occurrences allowed over duration.""" + limit: Int! = 60 + + """Number of seconds before limit is reset.""" + duration: Int! = 60 +) on OBJECT | FIELD_DEFINITION + +directive @isVIP on FIELD_DEFINITION | OBJECT + +directive @hasPermission on FIELD_DEFINITION + +directive @requireElevatedPermission(operationDomain: ElevatedPermissionOperationDomain) on OBJECT | FIELD_DEFINITION + +directive @vipCliRequiredVersion(version: String!) on FIELD_DEFINITION | OBJECT + +"""Marks the audiences that can access a field.""" +directive @audience(values: [ApiAudience!]!) on FIELD_DEFINITION + +"""Assigns a field to a public API domain.""" +directive @domain(name: ApiDomain!) on FIELD_DEFINITION + +enum ElevatedPermissionOperationDomain { + USER_MANAGEMENT +} + +"""Input for starting the Salesforce OAuth flow for Agentforce.""" +input StartAgentforceOAuthInput { + """The site ID to authorize for Agentforce.""" + siteId: Int! +} + +"""The result of starting the Salesforce OAuth flow for Agentforce.""" +type StartAgentforceOAuthPayload { + """The Salesforce authorization URL to send the user to.""" + authorizationUrl: String! + + """The OAuth state value to verify on completion.""" + state: String! +} + +"""Input for completing the Salesforce OAuth flow for Agentforce.""" +input CompleteAgentforceOAuthInput { + """The authorization code returned by Salesforce.""" + code: String! + + """The OAuth state value returned by Salesforce.""" + state: String! +} + +"""The result of completing the Salesforce OAuth flow for Agentforce.""" +type CompleteAgentforceOAuthPayload { + """The ingestion API endpoint configured for Agentforce.""" + ingestionApiEndpoint: String! + + """The Salesforce instance URL connected to Agentforce.""" + salesforceInstanceUrl: String! +} + +input GenerateAgentforceSetupUrlInput { + """The unique ID of the Environment""" + siteId: Int! + + """Blog ID for the setup wizard. Use 1 for single-site environments.""" + blogId: Int! + + """Callback URL for setup completion""" + callbackUrl: String! +} + +type GenerateAgentforceSetupUrlPayload { + """The HMAC-signed Salesforce Lightning URL for the WP Agent Setup wizard""" + setupUrl: String! +} + +"""The root mutation type for the public API.""" +type Mutation { + """ + Start the Salesforce OAuth flow for Agentforce and return the authorization URL. + """ + startAgentforceOAuth( + """The site to authorize for Agentforce.""" + input: StartAgentforceOAuthInput! + ): StartAgentforceOAuthPayload! + + """ + Complete the Salesforce OAuth flow for Agentforce and persist credentials. + """ + completeAgentforceOAuth( + """The authorization code and state returned by Salesforce.""" + input: CompleteAgentforceOAuthInput! + ): CompleteAgentforceOAuthPayload! + + """ + Generate a signed WP Agent Setup wizard URL with HMAC-SHA256 query string protection. + """ + generateAgentforceSetupUrl( + """The parameters for generating a signed setup URL.""" + input: GenerateAgentforceSetupUrlInput! + ): GenerateAgentforceSetupUrlPayload! + + """Enable a feature flag for an application.""" + enableFeature( + """The application and feature values to enable.""" + input: AppFeatureInput + ): AppFeaturePayload + + """Disable a feature flag for an application.""" + disableFeature( + """The application and feature values to disable.""" + input: AppFeatureInput + ): AppFeaturePayload + + """Activate a certificate for all domains on a site""" + activateCertificateBySite( + """The site and certificate values used for activation.""" + input: ActivateCertificateBySiteInput + ): ActivateCertificateBySitePayload + + """Debug page cache object""" + debugPageCache( + """The application, environment, URL, and request details to debug.""" + input: DebugPageCacheInput + ): DebugPageCachePayload! + + """Purge page cache object(s)""" + purgePageCache( + """The application, environment, and URLs to purge.""" + input: PurgePageCacheInput + ): PurgePageCachePayload! + + """Start a custom deploy on an environment.""" + startCustomDeploy( + """The environment and artifact details for the custom deploy.""" + input: AppEnvironmentCustomDeployInput + ): AppEnvironmentCustomDeployPayload + + """Enable custom deploys on an environment.""" + enableCustomDeploy( + """The application and environment to enable.""" + input: AppEnvironmentEnableDisableCustomDeployInput + ): AppEnvironmentEnableDisableCustomDeployPayload + + """Disable custom deploys on an environment.""" + disableCustomDeploy( + """The application and environment to disable.""" + input: AppEnvironmentEnableDisableCustomDeployInput + ): AppEnvironmentEnableDisableCustomDeployPayload + + """Generate a custom deploy access token.""" + generateCustomDeployAccess( + """The environments the token should allow access to.""" + input: GenerateCustomDeployAccessInput + ): GenerateCustomDeployAccessPayload + + """Validate custom deploy access for an application and environment.""" + validateCustomDeployAccess( + """The application and environment identifiers to validate.""" + input: ValidateCustomDeployAccessInput + ): ValidateCustomDeployAccessPayload + + """Manage Integration""" + manageIntegration( + """The integration scope, status, and configuration to apply.""" + input: ManageIntegrationInput! + ): Integration + + """Invite a user to an organization""" + createInvitation( + """The organization, email addresses, and permissions for the invitation.""" + input: CreateInvitationInput + ): CreateInvitationPayload! + + """Accept an invitation to an organization""" + acceptInvitation( + """The invitation code to accept.""" + input: AcceptInvitationInput + ): AcceptInvitationPayload! + + """Resend an invitation to an organization""" + resendInvitation( + """The invitation to resend.""" + input: ResendInvitationInput + ): ResendInvitationPayload! + + """Cancel an invitation to an organization""" + cancelInvitation( + """The invitation to cancel.""" + input: CancelInvitationInput + ): CancelInvitationPayload! + + """Set a user's organization role.""" + setUserOrganizationRole( + """The user and organization role assignment to apply.""" + input: UpdateUserOrganizationRoleInput + ): UpdateUserOrganizationRolePayload! + + """Set a user's application roles.""" + setUserApplicationRoles( + """The application role assignments to apply.""" + input: SetUserApplicationRolesInput + ): SetUserApplicationRolesPayload! + + """Custom Metric Thresholds management""" + setMetricThresholds( + """The environment, metric, and thresholds to create.""" + input: SetOrUpdateMetricThresholdsInput + ): SetOrUpdateMetricThresholdPayload + + """Update metric thresholds for an environment.""" + updateMetricThresholds( + """The environment, metric, and thresholds to update.""" + input: SetOrUpdateMetricThresholdsInput + ): SetOrUpdateMetricThresholdPayload + + """Delete metric thresholds for an environment.""" + deleteMetricThresholds( + """The environment, metric, and event type to delete.""" + input: DeleteMetricThresholdsInput + ): DeleteMetricThresholdsPayload + + """Enable New Relic on an environment.""" + enableNewRelic( + """The application and environment to enable New Relic on.""" + input: AppEnvironmentEnableNewRelicInput + ): AppEnvironmentEnableNewRelicPayload + + """Disable New Relic on an environment.""" + disableNewRelic( + """The application and environment to disable New Relic on.""" + input: AppEnvironmentDisableNewRelicInput + ): AppEnvironmentDisableNewRelicPayload + + """Add a New Relic user to an environment.""" + addNewRelicUser( + """The application, environment, and user details to add.""" + input: AppEnvironmentAddNewRelicUserInput + ): AppEnvironmentAddNewRelicUserPayload + + """Delete a New Relic user from an environment.""" + deleteNewRelicUser( + """The application, environment, and New Relic user to delete.""" + input: AppEnvironmentDeleteNewRelicUserInput + ): AppEnvironmentDeleteNewRelicUserPayload + + """Create a notification recipient.""" + addNotificationRecipient( + """The notification recipient to create.""" + input: AddNotificationRecipientInput + ): AddNotificationRecipientPayload + + """Update a notification recipient.""" + updateNotificationRecipient( + """The notification recipient changes to apply.""" + input: UpdateNotificationRecipientInput + ): UpdateNotificationRecipientPayload! + + """Delete a notification recipient.""" + deleteNotificationRecipient( + """The notification recipient to delete.""" + input: DeleteNotificationRecipientInput + ): DeleteNotificationRecipientPayload! + + """Create a notification subscription.""" + addNotificationSubscription( + """The notification subscription to create.""" + input: AddNotificationSubscriptionInput + ): AddNotificationSubscriptionPayload! + + """Delete a notification subscription.""" + deleteNotificationSubscription( + """The notification subscription to delete.""" + input: DeleteNotificationSubscriptionInput + ): DeleteNotificationSubscriptionPayload! + + """Update a notification subscription.""" + updateNotificationSubscription( + """The notification subscription changes to apply.""" + input: UpdateNotificationSubscriptionInput + ): UpdateNotificationSubscriptionPayload! + + """Send a test notification to a recipient.""" + sendTestNotification( + """The recipient and message details for the test notification.""" + input: SendTestNotificationInput + ): SendTestNotificationPayload! + + """ + Generate a Google Sheets access token from service account credentials. + """ + generateGoogleSheetsAccessToken( + """The service account credentials to exchange.""" + input: GenerateGoogleSheetsAccessTokenInput! + ): GenerateGoogleSheetsAccessTokenPayload! + + """Roll an environment back to a previous deployment.""" + rollback( + """The application, environment, and target deployment for the rollback.""" + input: RollbackInput + ): RollbackPayload! + + """Create a certificate signing request.""" + createCSR( + """The client, domain, and CSR details to generate.""" + input: CreateCSRInput + ): CreateCSRPayload! + + """Add a certificate to a domain.""" + addCertificate( + """The certificate details to add.""" + input: AddCertificateInput + ): AddCertificatePayload! + + """Update an existing certificate.""" + updateCertificate( + """The certificate details to update.""" + input: UpdateCertificateInput + ): UpdateCertificatePayload! + + """Activate a certificate on one or more domains.""" + activateCertificate( + """The domains and certificate to activate.""" + input: ActivateCertificateInput + ): ActivateCertificatePayload! + + """Decode a certificate signing request.""" + decodeCSR( + """The CSR string to decode.""" + input: DecodeCSRInput + ): CSRDecoded! + + """Delete a certificate.""" + deleteCertificate( + """The certificate to delete.""" + input: DeleteCertificateInput + ): DeleteCertificatePayload! + + """Purpose Token Management""" + deactivatePurposeToken( + """The purpose token to deactivate.""" + input: DeactivatePurposeTokenInput + ): DeactivatePurposeTokenPayload! + + """Email Verification Token Management""" + generateEmailVerificationToken( + """The email address to generate a verification token for.""" + input: GenerateEmailVerificationTokenInput! + ): EmailVerificationTokenPayload! + + """Validate an email verification token.""" + validateEmailVerificationToken( + """The email verification token to validate.""" + input: ValidateEmailVerificationTokenInput! + ): ValidateEmailVerificationTokenPayload! + + """Cancel a pending email verification token.""" + cancelPendingEmailVerificationToken( + """The pending email verification token to cancel.""" + input: CancelEmailVerificationTokenInput + ): CancelPendingEmailVerificationTokenPayload! + + """Create a user.""" + createUser( + """The user values to create.""" + input: CreateUserInput + ): CreateUserPayload! + + """ + Remove a user from an organization (removes all roles and applications permissions) + """ + removeUserFromOrganization( + """The user and organization to remove.""" + input: RemoveUserFromOrganizationInput + ): RemoveUserFromOrganizationPayload! + + """Update a user's GitHub username or email address""" + updateUser( + """The user changes to apply.""" + input: UpdateUserInput + ): UpdateUserPayload! + + """Trigger a sync for an application environment.""" + syncEnvironment( + """The application and environment to sync.""" + input: AppEnvironmentSyncInput + ): AppEnvironmentSyncPayload! + + """Generate a new token for the current user.""" + generateUserToken( + """The token lifetime settings.""" + input: UserTokenGenerationInput + ): UserTokenGenerationPayload! + + """Deactivate one of the current user's tokens.""" + deactivateUserToken( + """The token to deactivate.""" + input: DeactivateUserTokenInput + ): DeactivateUserTokenPayload! + + """Abort a media import.""" + abortMediaImport( + """The media import to abort.""" + input: AppEnvironmentAbortMediaImportInput + ): AppEnvironmentAbortMediaImportPayload! + + """Activate a Let's Encrypt TLS certificate for a domain.""" + activateLetsEncryptOnDomainForAppEnvironment( + """The environment and domain to activate Let's Encrypt on.""" + input: AppEnvironmentActivateLetsEncryptOnDomainInput + ): AppEnvironmentActivateLetsEncryptOnDomainPayload! + + """Add basic auth users to an environment.""" + addBasicAuth( + """The basic auth users to add.""" + input: AppEnvironmentBasicAuthInput + ): AppEnvironmentBasicAuthPayload! + + """Add a domain to an environment.""" + addDomainToAppEnvironment( + """The environment and domain to add.""" + input: AppEnvironmentAddDomainInput + ): AppEnvironmentAddDomainPayload! + + """Add an environment variable to an application environment.""" + addEnvironmentVariable( + """The application, environment, and variable values to add.""" + input: EnvironmentVariableInput + ): EnvironmentVariablesPayload + + """Sync request stats for an environment.""" + addRequestStats( + """The environment and date range to sync.""" + input: AppEnvironmentAddRequestStatsInput + ): AppEnvironmentAddRequestStatsPayload + + """Stop a running WP-CLI command""" + cancelWPCLICommand( + """The GUID of the command to cancel.""" + input: CancelWPCLICommandInput + ): CancelWPCLICommandPayload! + + """Repository Management""" + changeRepo( + """The application, environment, and branch to switch to.""" + input: CodebaseChangeRepoInput + ): CodebaseChangeRepoResult! + + """Complete an Elasticsearch upgrade.""" + completeElasticsearchUpgrade( + """The environment whose upgrade should be completed.""" + input: AppEnvironmentCompleteElasticsearchUpgradeInput! + ): AppEnvironmentElasticsearchUpgradePayload! + + """ + Create a new non-production environment as a child of a production environment + """ + createChildEnvironment( + """The parent application and child environment settings.""" + input: AppEnvironmentCreateChildEnvironmentInput! + ): AppEnvironmentCreateChildEnvironmentPayload! + + """Create a new WASM edge worker on an environment.""" + createEdgeWorker( + """The edge worker to create.""" + input: CreateEdgeWorkerInput! + ): EdgeWorker + + """Remove a domain from an environment.""" + deactivateDomainOnAppEnvironment( + """The environment and domain to deactivate.""" + input: AppEnvironmentDeactivateDomainInput + ): AppEnvironmentDeactivateDomainPayload! + + """Delete backup shipping configuration.""" + deleteBackupShippingConfigV2( + """The backup shipping configuration to delete.""" + input: AppEnvironmentBackupShippingDeleteInput + ): AppEnvironmentBackupShippingOperationResultPayload! + + """Delete basic auth users from an environment.""" + deleteBasicAuth( + """The basic auth users to delete.""" + input: AppEnvironmentBasicAuthDeleteInput + ): AppEnvironmentBasicAuthPayload! + + """Permanently delete a WASM edge worker.""" + deleteEdgeWorker( + """The edge worker to delete.""" + input: DeleteEdgeWorkerInput! + ): Boolean + + """Delete an environment variable from an application environment.""" + deleteEnvironmentVariable( + """The application, environment, and variable values to delete.""" + input: EnvironmentVariableInput + ): EnvironmentVariablesPayload + + """Delete an identity provider.""" + deleteIdentityProvider( + """The identity provider to delete.""" + input: DeleteIdentityProviderInput + ): DeleteIdentityProviderPayload! + + """Delete log shipping configuration.""" + deleteLogShippingConfigV2( + """The log shipping configuration to delete.""" + input: AppEnvironmentLogShippingDeleteInput + ): AppEnvironmentLogShippingOperationResultPayload! + + """Delete an organization auth domain.""" + deleteOrganizationAuthDomain( + """The auth domain to delete.""" + input: OrganizationAuthDomainDeleteInput + ): OrganizationAuthDomainDeletePayload! + + """Disable enforced SSO access for an organization.""" + disableEnforceSSOAccess( + """The organization ID to disable enforced SSO access for.""" + organizationId: Int! + ): Boolean! + + """Disable encryption for an identity provider.""" + disableIdentityProviderEncryption( + """The identity provider to disable encryption for.""" + input: EnableIdentityProviderEncryptionInput + ): EnableIdentityProviderEncryptionPayload! + + """Edit basic auth users on an environment.""" + editBasicAuth( + """The basic auth users to update.""" + input: AppEnvironmentBasicAuthInput + ): AppEnvironmentBasicAuthPayload! + + """Enforce SSO Access""" + enableEnforceSSOAccess( + """The organization ID to require SSO access for.""" + organizationId: Int! + ): Boolean! + + """Enable encryption for an identity provider.""" + enableIdentityProviderEncryption( + """The identity provider to enable encryption for.""" + input: EnableIdentityProviderEncryptionInput + ): EnableIdentityProviderEncryptionPayload! + + """Enable launch mode for an environment.""" + enableLaunchMode( + """The environment and launch mode settings to apply.""" + input: AppEnvironmentEnableLaunchModeInput + ): AppEnvironmentEnableLaunchModePayload + + """Enable phpMyAdmin for an environment.""" + enablePHPMyAdmin( + """The environment to enable phpMyAdmin for.""" + input: EnablePhpMyAdminInput + ): EnablePhpMyAdminPayload + + """Enqueue an Elasticsearch upgrade.""" + enqueueElasticsearchUpgrade( + """The environment and version to upgrade.""" + input: AppEnvironmentEnqueueElasticsearchUpgradeInput! + ): AppEnvironmentElasticsearchUpgradePayload! + + """Generate a presigned download URL for a copied database backup.""" + generateDBBackupCopyUrl( + """The backup copy to generate a URL for.""" + input: AppEnvironmentGenerateDBBackupCopyUrlInput + ): AppEnvironmentGenerateDBBackupCopyUrlPayload + + """Generate a live backup copy download URL.""" + generateLiveBackupCopyDownloadURL( + """The live backup copy to generate a URL for.""" + input: AppEnvironmentLiveBackupCopyDownloadURLInput! + ): AppEnvironmentLiveBackupCopyDownloadURLPayload + + """Generate a signed URL for a media export artifact.""" + generateMediaExportSignedUrl( + """The export target and identifiers to generate a URL for.""" + input: AppEnvironmentGenerateMediaExportSignedUrlInput + ): AppEnvironmentGenerateMediaExportSignedUrlPayload + + """Generate temporary phpMyAdmin access for an environment.""" + generatePHPMyAdminAccess( + """The environment to generate access for.""" + input: GeneratePhpMyAdminAccessInput + ): GeneratePhpMyAdminAccessPayload + + """Mark an application environment as launched.""" + launchApplication( + """The application and environment to update.""" + input: AppEnvironmentLaunchedInput + ): AppEnvironmentLaunchedPayload + + """Replace all auth domains for an organization.""" + replaceOrganizationAuthDomains( + """The organization and domains to store.""" + input: OrganizationAuthDomainReplaceInput + ): OrganizationAuthDomainReplacePayload! + + """Request a feature upgrade for an organization or application.""" + requestFeatureUpgrade( + """The organization, optional application, and feature to request.""" + input: RequestFeatureUpgradeInput + ): RequestFeatureUpgradePayload + + """Retire a non-production environment.""" + retireEnvironment( + """The environment to retire.""" + input: AppEnvironmentRetireInput + ): AppEnvironmentRetirePayload! + + """Create or update an identity provider.""" + saveIdentityProvider( + """The identity provider values to save.""" + input: SaveIdentityProviderInput + ): SaveIdentityProviderPayload! + + """Create or update an organization auth domain.""" + saveOrganizationAuthDomain( + """The auth domain values to save.""" + input: OrganizationAuthDomainCreateInput + ): OrganizationAuthDomainPayload! + + """Enable or disable an existing WASM edge worker.""" + setEdgeWorkerActive( + """The edge worker and desired active state.""" + input: SetEdgeWorkerActiveInput! + ): EdgeWorker + + """Update validation settings for an identity provider.""" + setIdentityProviderValidations( + """The identity provider validation settings to apply.""" + input: SetIdentityProviderValidationsInput! + ): SetIdentityProviderValidationsPayload! + + """Start copying a database backup.""" + startDBBackupCopy( + """The backup copy request.""" + input: AppEnvironmentStartDBBackupCopyInput + ): AppEnvironmentStartDBBackupCopyPayload! + + """Start importing data into an environment.""" + startImport( + """The import settings to apply.""" + input: AppEnvironmentImportInput + ): AppEnvironmentImportPayload! + + """Start a live backup copy.""" + startLiveBackupCopy( + """The live backup copy configuration.""" + input: LiveBackupCopyConfigInput! + ): AppEnvironmentStartLiveBackupCopyPayload! + + """Start a media export for an environment.""" + startMediaExport( + """The application, environment, and export options to use.""" + input: StartMediaExportInput + ): StartMediaExportPayload + + """Import media into an environment.""" + startMediaImport( + """The media import request.""" + input: AppEnvironmentStartMediaImportInput + ): AppEnvironmentMediaImportPayload + + """Switch the primary domain for an environment.""" + switchEnvironmentPrimaryDomain( + """The environment and domain to make primary.""" + input: AppEnvironmentPrimaryDomainSwitchInput + ): AppEnvironmentPrimaryDomainSwitchPayload! + + """Trigger Agentforce sync to push WordPress content to Salesforce""" + triggerAgentforceSync( + """The application, environment, and optional network site to sync.""" + input: TriggerAgentforceSyncInput! + ): TriggerAgentforceSyncPayload! + + """Trigger a database backup.""" + triggerDatabaseBackup( + """The database backup request.""" + input: AppEnvironmentTriggerDBBackupInput + ): AppEnvironmentTriggerDBBackupPayload! + + """Execute a WP-CLI command on an environment.""" + triggerWPCLICommandOnAppEnvironment( + """The environment and command to run.""" + input: AppEnvironmentTriggerWPCLICommandInput + ): AppEnvironmentTriggerWPCLICommandPayload! + + """Update backup shipping configuration.""" + updateBackupShippingConfigV2( + """The backup shipping configuration to store.""" + input: AppEnvironmentBackupShippingV2Input + ): AppEnvironmentBackupShippingOperationResultPayload! + + """Enable or disable backup shipping.""" + updateBackupShippingStatusV2( + """The backup shipping status to apply.""" + input: AppEnvironmentBackupShippingUpdateStatusInput + ): AppEnvironmentBackupShippingOperationResultPayload! + + """Update the custom error page configuration for an environment.""" + updateCustomErrorPageConfig( + """The environment and custom error page settings to apply.""" + input: UpdateCustomErrorPageConfigInput! + ): CustomErrorPageConfig! + + """Update defensive mode configuration.""" + updateDefensiveModeConfig( + """The defensive mode configuration to store.""" + input: AppEnvironmentDefensiveModeConfigInput + ): AppEnvironmentDefensiveModeOperationResultPayload! + + """Enable or disable defensive mode.""" + updateDefensiveModeStatus( + """The defensive mode status to apply.""" + input: AppEnvironmentDefensiveModeUpdateStatusInput + ): AppEnvironmentDefensiveModeOperationResultPayload! + + """Update an existing WASM edge worker.""" + updateEdgeWorker( + """The edge worker changes to apply.""" + input: UpdateEdgeWorkerInput! + ): EdgeWorker + + """Update a multisite subsite domain.""" + updateEnvironmentSubsiteDomain( + """The subsite domain update to apply.""" + input: AppEnvironmentUpdateSubsiteDomainInput + ): AppEnvironmentUpdateSubsiteDomainPayload! + + """Update an environment variable on an application environment.""" + updateEnvironmentVariable( + """The application, environment, and variable values to update.""" + input: EnvironmentVariableInput + ): EnvironmentVariablesPayload + + """Update HSTS settings for an environment.""" + updateHSTSSettings( + """The HSTS settings to apply.""" + input: AppEnvironmentHSTSSettingsInput + ): AppEnvironmentHSTSSettingsPayload + + """Update IP-based access restrictions for an environment.""" + updateIPAccessRestrictions( + """The environment and IP access restriction settings to apply.""" + input: EdgeConfigUpdateIPAccessRestrictionsInput + ): EdgeConfigAccessRestrictionsIp + + """Update log shipping configuration.""" + updateLogShippingConfigV2( + """The log shipping configuration to store.""" + input: AppEnvironmentLogShippingV2Input + ): AppEnvironmentLogShippingOperationResultPayload! + + """Enable or disable log shipping.""" + updateLogShippingStatusV2( + """The log shipping status to apply.""" + input: AppEnvironmentLogShippingUpdateStatusInput + ): AppEnvironmentLogShippingOperationResultPayload! + + """Plugin Update""" + updatePlugin( + """The application, environment, and plugin version details to update.""" + input: CodebaseUpdatePluginInput + ): CodebaseUpdatePluginResult! + + """Update software settings for an application environment.""" + updateSoftwareSettings( + """The application, environment, and software version to update.""" + input: AppEnvironmentSoftwareSettingsInput + ): AppEnvironmentSoftwareSettings + + """Update user-agent-based access restrictions for an environment.""" + updateUserAgentAccessRestrictions( + """The environment and user-agent access restriction settings to apply.""" + input: EdgeConfigUpdateUserAgentAccessRestrictionsInput + ): EdgeConfigAccessRestrictionsUserAgent + + """Update the launch status for a WordPress site.""" + updateWPSiteLaunchStatus( + """The application, environment, site, and launch status to update.""" + input: WPSiteLaunchStatusInput + ): WPSiteLaunchStatusPayload! + + """Validate backup shipping configuration.""" + validateBackupShippingConfigV2( + """The backup shipping configuration to validate.""" + input: AppEnvironmentBackupShippingV2Input + ): AppEnvironmentBackupShippingOperationResultPayload! + + """Validate log shipping configuration.""" + validateLogShippingConfigV2( + """The log shipping configuration to validate.""" + input: AppEnvironmentLogShippingV2Input + ): AppEnvironmentLogShippingOperationResultPayload! + + """Validate the current phpMyAdmin access token.""" + validatePHPMyAdminAccess: ValidatePhpMyAdminAccessPayload + + """Verify a DNS TXT record""" + verifyDnsTxtRecord( + """The domain to verify.""" + input: VerifyDnsTxtRecordInput + ): VerifyDnsTxtRecordPayload! +} + +""" +An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +""" +type App implements Model { + """The unique identifier for the application.""" + id: Int + + """The display name of the application.""" + name: String + + """ + The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + """ + environments( + """The environment ID to filter by.""" + id: Int + + """The environment name to filter by.""" + name: String + + """The environment type to filter by.""" + type: String + + """Filter environments by multisite state.""" + isMultisite: Boolean + + """Filter environments by launch state.""" + launched: Boolean + + """Exclude environments with these unique labels.""" + excludeUniqueLabels: [String] + ): [AppEnvironment] + + """ + The primary production environment for the application. This is the most common jump-off point for nested operational reads (commands, logs, events, deployments, backups, and more). + """ + primaryEnvironment: AppEnvironment + + """The source repository for the application in `owner/name` format.""" + repo: String + + """Repository metadata for the application's source code.""" + repository: GitRepository + + """The identifier of the organization that owns the application.""" + organizationId: Int + + """The organization that owns the application.""" + organization: Organization + + """The VIP support package assigned to the application.""" + supportPackage: String + + """The application platform type, such as WordPress or Node.js.""" + type: String + + """The internal numeric identifier for the application type.""" + typeId: Int + + """Pageview metrics for the application.""" + pageviews: Pageviews + + """The feature flags currently configured for the application.""" + features: [Feature] + + """When the application was created.""" + createdAt: String + + """Whether the application is currently active.""" + active: Boolean + + """The current VIP service status for the application.""" + serviceStatus: String + + """Permission checks for the current user on this application.""" + permissions( + """The permission keys to evaluate.""" + permissions: [String] + ): [PermissionResult] + + """Notification subscriptions configured for this application.""" + notificationSubscriptions( + """The maximum number of subscriptions to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """Filter subscriptions by active status.""" + active: Boolean + + """Filter subscriptions for a specific notification recipient.""" + notificationRecipientId: Int + + """ + Return organization-level subscriptions for the app's organization instead of app-level subscriptions. + """ + organizationSubscriptionsOnly: Boolean + + """Filter subscriptions by their VIN flag.""" + vin: Boolean + ): NotificationSubscriptionList + + """A single notification subscription on this application.""" + notificationSubscription( + """The notification subscription ID.""" + id: Int! + ): NotificationSubscription +} + +"""A paginated list of applications.""" +type AppList implements ModelList { + """The total number of matching applications.""" + total: Int + + """The cursor for the next page of applications.""" + nextCursor: String + + """The applications returned in the current page.""" + nodes: [App] + + """A legacy alias for `nodes`.""" + edges: [App] +} + +"""Input for enabling or disabling an application feature.""" +input AppFeatureInput { + """The application ID to update.""" + id: Int + + """The feature flag name.""" + name: String + + """The optional feature flag context.""" + context: String +} + +"""The application feature state after a feature mutation.""" +type AppFeaturePayload { + """The total number of features configured for the application.""" + total: Int + + """The features currently configured for the application.""" + features: [Feature] +} + +"""Input for activating a certificate across all domains on a site.""" +input ActivateCertificateBySiteInput { + """The site ID whose domains should receive the certificate.""" + clientSiteId: Int! + + """The certificate ID to activate.""" + certificateId: Int! + + """Whether to skip configuration reloads while applying the certificate.""" + skipConfigReloads: Boolean + + """Whether to bypass domain validation before activating the certificate.""" + bypassDomainValidation: Boolean +} + +"""The result of a site-wide certificate activation request.""" +type ActivateCertificateBySitePayload { + """The status returned by the activation request.""" + status: String! + + """The domain IDs that failed certificate activation.""" + failedDomains: [Int] +} + +"""An audit event recorded for an application or environment.""" +type AuditEvent { + """The unique identifier for the audit event.""" + id: String + + """The application associated with the event.""" + app: App + + """The environment ID associated with the event.""" + environmentId: Int + + """The environment associated with the event.""" + environment: AppEnvironment + + """The event type.""" + type: String + + """The event title.""" + title: String + + """The event description.""" + description: String + + """The actor that triggered the event.""" + actor: AuditEventActor + + """The target affected by the event.""" + target: AuditEventTarget + + """The source system that produced the event.""" + source: AuditEventSource + + """Additional metadata attached to the event.""" + meta: [AuditEventMeta] + + """When the event was recorded.""" + recordedTime: Date +} + +"""The actor that triggered an audit event.""" +type AuditEventActor { + """The unique identifier for the actor.""" + id: String + + """The actor type.""" + type: String + + """The permission associated with the actor, if any.""" + permission: String + + """The display name of the actor.""" + displayName: String + + """The avatar URL for the actor.""" + avatarUrl( + """The requested avatar width in pixels.""" + width: Int + ): String + + """Whether the actor is a VIP user.""" + isVIP: Boolean +} + +"""A paginated list of audit events.""" +type AuditEventList { + """The total number of matching audit events.""" + total: Int + + """The cursor for the next page of audit events.""" + nextCursor: String + + """The audit events returned in the current page.""" + nodes: [AuditEvent] + + """A legacy alias for `nodes`.""" + edges: [AuditEvent] +} + +"""A metadata entry attached to an audit event.""" +type AuditEventMeta { + """The metadata key.""" + key: String! + + """The metadata value.""" + value: String +} + +"""The source system that produced an audit event.""" +type AuditEventSource { + """The source type.""" + type: String + + """The source version.""" + version: String +} + +"""The target affected by an audit event.""" +type AuditEventTarget { + """The unique identifier for the target.""" + id: String + + """The target type.""" + type: String +} + +"""A count of audit events grouped by type.""" +type AuditEventCount { + """The event type being counted.""" + type: String + + """The number of events for the type.""" + count: Int +} + +"""The lifecycle states for a build.""" +enum BuildStatus { + """The build is queued and has not started yet.""" + QUEUED + + """The build is currently running.""" + RUNNING + + """The build finished with a failure.""" + FAILED + + """The build finished successfully.""" + SUCCESS +} + +"""A build executed for an application environment.""" +type Build implements Model { + """The unique identifier for the build.""" + id: Int + + """The vendor-specific build identifier.""" + vendor_id: Int + + """The current build status.""" + status: BuildStatus + + """When the build was queued.""" + queued_date: Date + + """When the build started.""" + start_date: Date + + """When the build finished.""" + finish_date: Date + + """The commit SHA built by this job.""" + commit_sha: String + + """The author of the commit built by this job.""" + commit_author: String! + + """When the built commit was created.""" + commit_time: Date! + + """The build log lines.""" + logs: [String] +} + +"""A paginated list of builds.""" +type BuildList implements ModelList { + """The total number of matching builds.""" + total: Int + + """The cursor for the next page of builds.""" + nextCursor: String + + """The builds returned in the current page.""" + nodes: [Build] +} + +"""Input for purging page cache entries.""" +input PurgePageCacheInput { + """The application ID whose cache should be purged.""" + appId: Int! + + """The environment ID whose cache should be purged.""" + environmentId: Int! + + """The URLs to purge from page cache.""" + urls: [String!]! +} + +"""The result of a page cache purge request.""" +type PurgePageCachePayload { + """The URLs that were targeted for purge.""" + urls: [String!]! + + """Whether the purge request succeeded.""" + success: Boolean! +} + +"""A request header to include in a cache debug request.""" +input RequestHeader { + """The header name.""" + name: String! + + """The header value.""" + value: String! +} + +"""A response header returned from a cache debug request.""" +type ResponseHeader { + """The header name.""" + name: String! + + """The header value.""" + value: String! +} + +"""A server response captured during cache debugging.""" +type ServerResponse { + """The response headers.""" + headers: [ResponseHeader!]! + + """The HTTP status code.""" + statusCode: Int! +} + +"""Input for debugging page cache behavior.""" +input DebugPageCacheInput { + """The application ID whose cache should be debugged.""" + appId: Int! + + """The environment ID whose cache should be debugged.""" + environmentId: Int! + + """The request headers to include in the debug request.""" + requestHeaders: [RequestHeader!] + + """The HTTP method to use for the debug request.""" + requestMethod: String + + """The point of presence to target, if supported.""" + pop: String + + """The URL to debug.""" + url: String! +} + +"""An insight produced by page cache debugging.""" +type DebugPageCacheInsight { + """The insight category.""" + category: String! + + """The insight rendered as HTML.""" + html: String! + + """Whether this is the final insight in the chain.""" + final: Boolean! + + """The display name of the insight.""" + name: String! + + """The insight type.""" + type: String! +} + +"""The result of a page cache debug request.""" +type DebugPageCachePayload { + """The edge response observed during debugging.""" + edge: ServerResponse + + """The insights generated during debugging.""" + insights: [DebugPageCacheInsight!] + + """The origin response observed during debugging.""" + origin: ServerResponse + + """Whether the debug request succeeded.""" + success: Boolean! + + """The URL that was debugged.""" + url: String! +} + +"""Input for enabling or disabling custom deploys on an environment.""" +input AppEnvironmentEnableDisableCustomDeployInput { + """The application ID that owns the environment.""" + appId: Int! + + """The environment ID to update.""" + environmentId: Int! +} + +"""The result of enabling or disabling custom deploys.""" +type AppEnvironmentEnableDisableCustomDeployPayload { + """Whether the operation succeeded.""" + success: Boolean! +} + +"""Input for starting a custom deploy.""" +input AppEnvironmentCustomDeployInput { + """The application ID, when required by the caller.""" + id: Int + + """The environment ID to deploy to.""" + environmentId: Int + + """The deployment artifact filename.""" + basename: String + + """The checksum of the deployment artifact.""" + checksum: String + + """The deploy message to record.""" + deployMessage: String +} + +"""The result of starting a custom deploy.""" +type AppEnvironmentCustomDeployPayload { + """The application being deployed.""" + app: App + + """Whether the custom deploy request succeeded.""" + success: Boolean + + """A human-readable message about the deploy request.""" + message: String +} + +"""Input for validating custom deploy access.""" +input ValidateCustomDeployAccessInput { + """The application identifier to validate.""" + app: String! + + """The environment identifier to validate.""" + env: String! +} + +"""The result of validating custom deploy access.""" +type ValidateCustomDeployAccessPayload { + """Whether the custom deploy access is valid.""" + success: Boolean + + """The resolved application ID.""" + appId: Int + + """The resolved environment ID.""" + envId: Int + + """The resolved environment type.""" + envType: String + + """The resolved unique environment label.""" + envUniqueLabel: String + + """The primary domain name for the environment.""" + primaryDomainName: String + + """Whether the environment is launched.""" + launched: Boolean +} + +"""Input for generating a custom deploy access token.""" +input GenerateCustomDeployAccessInput { + """The environment IDs the token should allow access to.""" + environmentIds: [Int!] +} + +"""The result of generating a custom deploy access token.""" +type GenerateCustomDeployAccessPayload { + """The generated custom deploy access token.""" + token: String + + """When the token expires.""" + expiresAt: Date +} + +"""A database partitioning dataset available for an environment.""" +type DBPartitioningDataset { + """The internal dataset name.""" + name: String + + """The display label for the dataset.""" + displayName: String +} + +"""A legacy deployment record for an application environment.""" +type Deploy { + """The unique identifier for the deployment.""" + id: Int + + """When the deployment finished.""" + deployed_at: String + + """The repository deployed.""" + repo: String + + """The branch that was deployed.""" + branch: String + + """The API user ID that initiated the deployment.""" + deployer_api_user_id: Int + + """The commits included in the deployment.""" + commits( + """The maximum number of commits to return.""" + first: Int + ): GitCommitList +} + +"""A paginated list of deployments.""" +type DeployList { + """The total number of matching deployments.""" + total: Int + + """The cursor for the next page of deployments.""" + nextCursor: String + + """The deployments returned in the current page.""" + nodes: [Deploy] + + """A legacy alias for `nodes`.""" + edges: [Deploy] +} + +"""The possible statuses for a deployment step.""" +enum DeploymentStepStatus { + """The step is currently running.""" + Running + + """The step is waiting to start.""" + Waiting + + """The step is pending.""" + Pending + + """The build phase is in progress.""" + Building + + """The build phase finished successfully.""" + BuildFinished + + """The build phase failed.""" + BuildError + + """The deployment phase is in progress.""" + Deploying + + """The step finished successfully.""" + Finished + + """The step finished with an error.""" + Error + + """The step was cancelled.""" + Cancelled +} + +"""A single step within a deployment.""" +type DeploymentStep { + """The step key.""" + step: String! + + """The current status of the step.""" + status: DeploymentStepStatus! + + """Whether the step is currently in progress.""" + inProgress: Boolean! + + """Whether the step is in an error state.""" + isError: Boolean! + + """When the step started.""" + startDate: Date + + """When the step finished.""" + finishDate: Date + + """The logs collected for the step.""" + logs: [String] + + """Whether logs are available for the current app type.""" + isLogsAvailableForAppType: Boolean + + """When the step logs expire.""" + logsExpireAt: Date +} + +"""A deployment for an application environment.""" +type Deployment implements Model { + """The unique identifier for the deployment.""" + id: Int! + + """The branch that was deployed.""" + branch: String! + + """The repository that was deployed.""" + repo: String! + + """The raw deployment status.""" + deployment_status: String! + + """When the deployment was triggered.""" + deployment_triggered_at: Date + + """When the deployment finished.""" + deployment_finished_at: Date + + """When the deployment record was created.""" + createdAt: Date + + """When the deployment was cancelled.""" + cancelledAt: Date + + """The deployed commit SHA.""" + commit_sha: String! + + """The author of the deployed commit.""" + commit_author: String + + """When the deployed commit was created.""" + commit_time: Date + + """The deployed commit description.""" + commit_description: String + + """The build associated with the deployment.""" + build: Build + + """Whether the deployment is in an error state.""" + isError: Boolean + + """Whether this is the latest deployment.""" + isLatest: Boolean + + """Whether the deployment is currently in progress.""" + inProgress: Boolean + + """The steps recorded for the deployment.""" + steps: [DeploymentStep] + + """Whether the deployment can be used for rollback.""" + isAvailableForRollback: Boolean + + """The user who initiated the deployment.""" + initiatedBy: User + + """The post-deploy actions job identifier.""" + postDeployActionsJob: String +} + +"""A paginated list of deployments.""" +type DeploymentList implements ModelList { + """The total number of matching deployments.""" + total: Int + + """The cursor for the next page of deployments.""" + nextCursor: String + + """The deployments returned in the current page.""" + nodes: [Deployment] +} + +"""A feature flag configured for an application.""" +type Feature implements Model { + """The unique identifier for the feature flag.""" + id: Int + + """The application ID that owns the feature flag.""" + appId: Int + + """The feature flag name.""" + name: String + + """The optional context for the feature flag.""" + context: String + + """Whether the feature flag is currently active.""" + active: Boolean +} + +"""A Git commit.""" +type GitCommit { + """The commit message headline.""" + messageHeadline: String + + """The commit message headline rendered as HTML.""" + messageHeadlineHTML: String + + """The commit message body.""" + messageBody: String + + """The commit message body rendered as HTML.""" + messageBodyHTML: String + + """The full commit message.""" + message: String + + """The full object ID for the commit.""" + oid: String + + """The abbreviated object ID for the commit.""" + abbreviatedOid: String + + """The author of the commit.""" + author: GitActor + + """When the commit was authored.""" + authoredDate: String + + """When the commit was committed.""" + committedDate: String + + """The number of lines deleted in the commit.""" + deletions: Int + + """The number of lines added in the commit.""" + additions: Int + + """The URL for the commit.""" + url: String +} + +"""The author or committer associated with a Git object.""" +type GitActor { + """The email address of the Git actor.""" + email: String + + """The display name of the Git actor.""" + name: String + + """The avatar URL for the Git actor.""" + avatarUrl( + """The requested avatar image size in pixels.""" + size: Int = 125 + ): String + + """The linked GitHub user, if available.""" + user: GitHubUser +} + +"""A Git repository.""" +type GitRepository { + """The repository name.""" + name: String + + """The repository owner or organization.""" + organization: String + + """The repository full name in `owner/name` format.""" + fullName: String + + """The source control platform.""" + platform: String + + """The HTML URL for the repository.""" + htmlUrl: String +} + +"""A paginated list of Git commits.""" +type GitCommitList { + """The cursor for the next page of commits.""" + nextCursor: String + + """The commits returned in the current page.""" + nodes: [GitCommit] + + """A legacy alias for `nodes`.""" + edges: [GitCommit] +} + +"""A GitHub issue or pull request comment.""" +type GitHubComment { + """The GitHub identifier for the comment.""" + id: ID + + """The API URL for the comment.""" + url: String + + """The HTML URL for the comment.""" + htmlUrl: String + + """The API URL for the related issue or pull request.""" + issueUrl: String + + """The GitHub user who authored the comment.""" + user: GitHubUser + + """When the comment was created.""" + createdAt: String + + """When the comment was last updated.""" + updatedAt: String + + """The comment body.""" + body: String +} + +"""A GitHub pull request.""" +type GitHubPullRequest implements Model { + """The GitHub identifier for the pull request.""" + id: Int + + """The pull request title.""" + title: String + + """The pull request number.""" + number: Int + + """The current pull request status.""" + status: String + + """The API URL for the pull request.""" + url: String + + """When the pull request was created.""" + createdAt: String + + """The labels applied to the pull request.""" + labels: [String] + + """The initial commit SHA for the pull request.""" + initialCommit: String + + """The total number of commits in the pull request.""" + totalCommits: Int + + """The API URL for the commits on the pull request.""" + commitsUrl: String + + """The GitHub user who opened the pull request.""" + user: GitHubUser + + """The API URL for the repository.""" + repositoryUrl: String + + """The API URL for the labels collection.""" + labelsUrl: String + + """The API URL for the comments collection.""" + commentsUrl: String + + """The API URL for the events collection.""" + eventsUrl: String + + """The pull request body.""" + body: String + + """Whether the pull request is locked.""" + locked: Boolean + + """The primary assignee on the pull request.""" + assignee: GitHubUser + + """The assignees on the pull request.""" + assignees: [GitHubUser] + + """The number of comments on the pull request.""" + comments: Int + + """When the pull request was last updated.""" + updatedAt: String + + """When the pull request was closed.""" + closedAt: String + + """VIP-specific metadata collected for the pull request.""" + vipMeta: VIPPRMeta +} + +"""A review comment on a GitHub pull request.""" +type GitHubPullRequestReviewComment { + """The GitHub identifier for the review comment.""" + id: ID + + """The API URL for the review comment.""" + url: String + + """The review ID associated with the comment.""" + pullRequest_review_id: Int + + """The diff hunk the comment refers to.""" + diffHunk: String + + """The file path the comment refers to.""" + path: String + + """The position within the diff.""" + position: Int + + """The original position within the diff.""" + originalPosition: Int + + """The commit SHA the comment refers to.""" + commitId: String + + """The original commit SHA the comment referred to.""" + originalCommitId: String + + """The GitHub user who authored the review comment.""" + user: GitHubUser + + """The review comment body.""" + body: String + + """When the review comment was created.""" + createdAt: String + + """When the review comment was last updated.""" + updatedAt: String + + """The HTML URL for the review comment.""" + htmlUrl: String + + """The API URL for the parent pull request.""" + pullRequestUrl: String +} + +"""A GitHub review on a pull request.""" +type GitHubReview { + """The GitHub identifier for the review.""" + id: ID + + """The GitHub user who submitted the review.""" + user: GitHubUser + + """The review body.""" + body: String + + """The review state.""" + state: String + + """The HTML URL for the review.""" + htmlUrl: String + + """The API URL for the parent pull request.""" + pullRequestUrl: String + + """When the review was submitted.""" + submittedAt: String + + """The commit SHA the review applies to.""" + commitId: String +} + +"""A GitHub user.""" +type GitHubUser { + """The GitHub identifier for the user.""" + id: ID + + """The GitHub login.""" + login: String + + """The avatar URL for the user.""" + avatarUrl: String + + """The user's gravatar identifier.""" + gravatarId: String + + """The API URL for the user.""" + url: String + + """The HTML URL for the user.""" + htmlUrl: String + + """The API URL for the user's followers.""" + followersUrl: String + + """The API URL template for the user's following list.""" + followingUrl: String + + """The API URL for the user's gists.""" + gistsUrl: String + + """The API URL template for the user's starred repositories.""" + starredUrl: String + + """The API URL for the user's subscriptions.""" + subscriptionsUrl: String + + """The API URL for the user's organizations.""" + organizationsUrl: String + + """The API URL for the user's repositories.""" + reposUrl: String + + """The API URL for the user's events.""" + eventsUrl: String + + """The API URL for the user's received events.""" + receivedEventsUrl: String + + """The GitHub account type.""" + type: String + + """Whether the user is a GitHub site admin.""" + siteAdmin: Boolean +} + +"""A paginated list of GitHub pull requests.""" +type GitHubPullRequestList implements ModelList { + """The total number of matching pull requests.""" + total: Int + + """The cursor for the next page of pull requests.""" + nextCursor: String + + """The pull requests returned in the current page.""" + nodes: [GitHubPullRequest] + + """A legacy alias for `nodes`.""" + edges: [GitHubPullRequest] +} + +"""Arbitrary JSON data.""" +scalar JSON + +""" +An integration entry returned in a list scoped to an app or environment. +""" +type IntegrationListItem { + """The unique identifier for the integration.""" + id: Int + + """The integration slug.""" + slug: String! + + """The current integration status.""" + status: String + + """The visibility setting for the integration.""" + visibility: String + + """The integrations or capabilities required by this integration.""" + requires: [String!]! + + """The integrations that depend on this integration.""" + requiredBy: [String!]! + + """Whether the integration is a must-use integration.""" + must_use: Boolean +} + +"""An integration entry returned in a list scoped to an organization.""" +type IntegrationClientListItem { + """The unique identifier for the integration.""" + id: Int + + """The integration slug.""" + slug: String! + + """The current integration status.""" + status: String + + """Whether the integration has active applications.""" + has_active_apps: Boolean + + """The visibility setting for the integration.""" + visibility: String + + """The integrations or capabilities required by this integration.""" + requires: [String!]! + + """The integrations that depend on this integration.""" + requiredBy: [String!]! + + """Whether the integration is a must-use integration.""" + must_use: Boolean +} + +""" +A list of integrations. Note: `nodes` contain `IntegrationListItem`, not `Integration`. +""" +type IntegrationList { + """The total number of matching integrations.""" + total: Int! + + """ + The integrations returned in the list as lightweight `IntegrationListItem` objects. + """ + nodes: [IntegrationListItem!]! +} + +"""A list of organization-scoped integrations.""" +type IntegrationClientList { + """The total number of matching integrations.""" + total: Int! + + """The integrations returned in the list.""" + nodes: [IntegrationClientListItem!]! +} + +"""An integration with configuration and related resources.""" +type Integration { + """The unique identifier for the integration.""" + id: Int + + """The integration slug.""" + slug: String! + + """The current integration status.""" + status: String + + """The integrations or capabilities required by this integration.""" + requires: [String!]! + + """The integrations that depend on this integration.""" + requiredBy: [String!]! + + """The integration configuration.""" + config: JSON + + """The network sites related to the integration.""" + network_sites( + """The maximum number of network sites to return.""" + limit: Int + + """The page number to return.""" + page: Int + + """A status filter for network sites.""" + status: String + + """A search string to filter network sites.""" + search: String + ): NetworkSitesResult + + """The applications related to the integration.""" + applications( + """The maximum number of applications to return.""" + limit: Int + + """The page number to return.""" + page: Int + ): ApplicationsResult + + """A single network site result related to the integration.""" + network_site: NetworkSiteResult + + """The application ID associated with the integration.""" + appId: Int + + """The environment ID associated with the integration.""" + envId: Int + + """The organization ID associated with the integration.""" + orgId: Int +} + +"""A result set of network sites for an integration.""" +type NetworkSitesResult { + """The network sites returned in the result.""" + items: [InflatedNetworkSite] + + """The total number of matching network sites.""" + total: Int + + """The related blueprint, if any.""" + blueprint: Blueprint +} + +"""A single network site result.""" +type NetworkSiteResult { + """The site URL.""" + url: String + + """The site home URL.""" + home_url: String +} + +"""A result set of applications for an integration.""" +type ApplicationsResult { + """The applications returned in the result.""" + items: [InflatedApplication] + + """The total number of matching applications.""" + total: Int +} + +"""Blueprint information related to an integration.""" +type Blueprint { + """The current blueprint status.""" + status: String + + """The blueprint configuration.""" + config: JSON + + """Whether a fresh blueprint is required.""" + requires_fresh_blueprint: Boolean +} + +"""A network site inflated with integration data.""" +type InflatedNetworkSite { + """The network site identifier.""" + id: String + + """The site URL.""" + url: String + + """The site home URL.""" + home_url: String + + """The integration status for the site.""" + status: String + + """The integration configuration for the site.""" + config: JSON +} + +"""An application inflated with integration data.""" +type InflatedApplication { + """The application identifier.""" + id: String + + """The application name.""" + name: String + + """Whether the application is multisite.""" + is_multisite: Boolean + + """The environments on the application.""" + environments: [Environment] +} + +"""A minimal environment reference for integration responses.""" +type Environment { + """The environment identifier.""" + id: Int + + """The environment name.""" + name: String +} + +"""Development environment configuration for integrations.""" +type IntegrationDevEnvConfig { + """The integration configuration data.""" + data: JSON +} + +"""Input for retrieving a specific integration.""" +input GetIntegrationInput { + """The integration slug.""" + slug: String! + + """The network site ID to scope the integration to.""" + networkSiteId: Int + + """The inflate mode to use for the response.""" + inflate: String +} + +""" +Input for managing an integration. Scope rules: provide either `organizationId`, or the pair `appId` + `environmentId` (optionally with `networkId`). +""" +input ManageIntegrationInput { + """ + The application ID for environment-scoped management. Must be provided together with `environmentId`, and must not be combined with `organizationId`. + """ + appId: Int + + """ + The environment ID for environment-scoped management. Must be provided together with `appId`, and must not be combined with `organizationId`. + """ + environmentId: Int + + """ + The organization ID for organization-scoped management. Must not be combined with `appId`, `environmentId`, or `networkId`. + """ + organizationId: Int + + """ + The network site ID for network-site scoped integration changes within an environment scope. + """ + networkId: Int + + """The integration slug.""" + slug: String! + + """The desired integration status.""" + status: String! + + """The integration configuration to apply.""" + config: JSON + + """ + Whether to apply the change to child environments when using app/environment scope. + """ + applyToChildEnvironments: Boolean +} + +"""An integration available in the Integration Center.""" +type IntegrationCenter implements Model { + """The unique identifier for the integration.""" + id: Int + + """The integration slug.""" + slug: String! + + """The display title of the integration.""" + title: String! + + """The serialized metadata for the integration.""" + meta: String! + + """The serialized block configuration for the integration.""" + blocks: String! + + """The visibility setting for the integration.""" + visibility: String! + + """The capabilities or dependencies required by the integration.""" + requires: [String!]! + + """The integrations that depend on this integration.""" + requiredBy: [String!]! + + """The site types that can use this integration.""" + allowedSiteTypes: [Int!]! +} + +"""A paginated list of Integration Center entries.""" +type IntegrationCenterList implements ModelList { + """The total number of matching integrations.""" + total: Int + + """The cursor for the next page of integrations.""" + nextCursor: String + + """The integrations returned in the current page.""" + nodes: [IntegrationCenter] + + """A legacy alias for `nodes`.""" + edges: [IntegrationCenter] +} + +"""An Integration Center category.""" +type IntegrationCenterCategory { + """The category slug.""" + slug: String! + + """The display name of the category.""" + name: String! +} + +"""A list of Integration Center categories.""" +type IntegrationCenterCategoryList { + """The total number of categories returned.""" + total: Int + + """The categories returned in the list.""" + nodes: [IntegrationCenterCategory] +} + +"""An invitation to join an organization.""" +type Invitation implements Model { + """The unique identifier for the invitation.""" + id: Int + + """The user who sent the invitation.""" + invitingUser: User + + """The organization the invitation belongs to.""" + organization: Organization + + """The email address the invitation was sent to.""" + emailAddress: String + + """The permissions granted by the invitation.""" + grantedPermissions: InvitationPermissions + + """The current invitation status.""" + status: String + + """When the invitation was created.""" + createdAt: String + + """When the invitation expires.""" + expiresAt: String + + """When the invitation was accepted.""" + acceptedAt: String + + """Whether the invitation can be resent.""" + isResendable: Boolean + + """Whether the invitation can be cancelled.""" + isCancelable: Boolean +} + +"""A paginated list of invitations.""" +type InvitationList { + """The total number of matching invitations.""" + total: Int + + """The cursor for the next page of invitations.""" + nextCursor: String + + """The invitations returned in the current page.""" + nodes: [Invitation] +} + +"""The permissions granted by an invitation.""" +type InvitationPermissions { + """The organization role granted by the invitation.""" + organizationRoleId: String + + """The application roles granted by the invitation.""" + applicationRoles: [InvitationPermissionsApplicationRole] +} + +"""An application role granted by an invitation.""" +type InvitationPermissionsApplicationRole { + """The application ID the role applies to.""" + appId: Int + + """The application the role applies to.""" + app: App + + """The application role ID granted by the invitation.""" + roleId: ApplicationRoleId + + """The application role granted by the invitation.""" + role: ApplicationRole +} + +"""Input for creating invitations.""" +input CreateInvitationInput { + """The organization ID to invite users into.""" + organizationId: Int! + + """The email addresses to invite.""" + emailAddresses: [String]! + + """The permissions to grant to invited users.""" + grantedPermissions: InvitationPermissionsInput! +} + +"""Input describing the permissions granted by an invitation.""" +input InvitationPermissionsInput { + """The organization role to grant.""" + organizationRoleId: OrgRoleId + + """The application roles to grant.""" + applicationRoles: [InvitationPermissionsApplicationRoleInput] +} + +"""An application role to grant within an invitation.""" +input InvitationPermissionsApplicationRoleInput { + """The application ID the role applies to.""" + appId: Int + + """The application role ID to grant.""" + roleId: ApplicationRoleId +} + +"""The result of creating invitations.""" +type CreateInvitationPayload { + """The invitations that were created.""" + invitations: [Invitation] +} + +"""Input for accepting an invitation.""" +input AcceptInvitationInput { + """The invitation code to accept.""" + invitationCode: String +} + +"""The result of accepting an invitation.""" +type AcceptInvitationPayload { + """The resulting invitation status.""" + status: String +} + +"""Input for resending an invitation.""" +input ResendInvitationInput { + """The invitation ID to resend.""" + invitationId: Int +} + +"""The result of resending an invitation.""" +type ResendInvitationPayload { + """The invitation that was resent.""" + invitation: Invitation +} + +"""Input for cancelling an invitation.""" +input CancelInvitationInput { + """The invitation ID to cancel.""" + invitationId: Int +} + +"""The result of cancelling an invitation.""" +type CancelInvitationPayload { + """The invitation that was cancelled.""" + invitation: Invitation +} + +"""A background job.""" +type Job implements JobInterface { + """The unique identifier for the job.""" + id: Int + + """The job type.""" + type: String + + """When the job completed.""" + completedAt: String + + """When the job was created.""" + createdAt: String + + """The current progress of the job.""" + progress: JobProgress + + """Whether the job currently holds an in-progress lock.""" + inProgressLock: Boolean + + """Additional metadata for the job.""" + metadata: [JobMetadata] +} + +"""Progress details for a job.""" +type JobProgress { + """The current status of the job.""" + status: String + + """The individual progress steps for the job.""" + steps: [JobProgressStep] +} + +"""A single progress step within a job.""" +type JobProgressStep { + """The display name of the step.""" + name: String + + """The step key.""" + step: String + + """The unique identifier for the step.""" + id: String + + """The current status of the step.""" + status: String +} + +"""Common fields shared by all job types.""" +interface JobInterface { + """The unique identifier for the job.""" + id: Int + + """The job type.""" + type: String + + """When the job completed.""" + completedAt: String + + """When the job was created.""" + createdAt: String + + """The current progress of the job.""" + progress: JobProgress + + """Whether the job currently holds an in-progress lock.""" + inProgressLock: Boolean + + """Additional metadata for the job.""" + metadata: [JobMetadata] +} + +"""A metadata entry attached to a job.""" +type JobMetadata { + """The metadata key.""" + name: String + + """The metadata value.""" + value: String +} + +"""A job that switches an environment's primary domain.""" +type PrimaryDomainSwitchJob implements JobInterface { + """The unique identifier for the job.""" + id: Int + + """The job type.""" + type: String + + """When the job completed.""" + completedAt: String + + """When the job was created.""" + createdAt: String + + """The current progress of the job.""" + progress: JobProgress + + """Whether the job currently holds an in-progress lock.""" + inProgressLock: Boolean + + """Additional metadata for the job.""" + metadata: [JobMetadata] + + """The domain being set as primary.""" + newDomain: Domain +} + +"""Media Import Configuration""" +type MediaImportConfig { + """Allowed File Types""" + allowedFileTypes: MediaImportAllowedFileTypes + + """Allowed File Size Limit""" + fileSizeLimitInBytes: BigInt + + """Allowed File Name Length""" + fileNameCharCount: Int +} + +"""A detected anomaly in an environment metric.""" +type MetricAnomaly { + """The unique identifier for the anomaly.""" + id: Int! + + """When the anomaly started.""" + startTime: String! + + """When the anomaly ended.""" + endTime: String + + """The metric value at the start of the anomaly.""" + startValue: Float + + """The metric value at the end of the anomaly.""" + endValue: Float + + """The anomaly detection algorithm version.""" + algorithmVersion: String + + """The custom metric threshold configuration ID, if any.""" + customMetricThresholdsConfigId: Int +} + +"""A list of anomalies returned for a metric query.""" +type MetricAnomaliesList { + """The query identifier for the anomalies request.""" + queryId: String! + + """The metric name queried.""" + metricName: String! + + """The site ID the anomalies belong to.""" + siteId: Int! + + """The environment ID the anomalies belong to.""" + environmentId: Int! + + """The total number of anomalies returned.""" + totalAnomalies: Int! + + """The anomalies returned for the query.""" + anomalies: [MetricAnomaly]! +} + +"""A table row used in anomaly context details.""" +type AnomalyContextTable { + """The item label.""" + item: String! + + """The count for the item.""" + count: Int! +} + +"""Context data associated with a metric anomaly.""" +interface AnomalyContextData { + """The context data type.""" + type: String +} + +"""Context data for a 429 anomaly.""" +type Anomaly429ContextData implements AnomalyContextData { + """The context data type.""" + type: String + + """The total number of requests in the anomaly window.""" + totalRequests: Int! + + """The top hosts contributing to the anomaly.""" + topHosts: [AnomalyContextTable]! + + """The top country codes contributing to the anomaly.""" + topCountryCodes: [AnomalyContextTable]! + + """The top user agents contributing to the anomaly.""" + topUserAgents: [AnomalyContextTable]! + + """The top remote addresses contributing to the anomaly.""" + topRemoteAddr: [AnomalyContextTable]! +} + +"""Detailed context for a metric anomaly.""" +type MetricAnomalyContext { + """The anomaly identifier.""" + id: Int! + + """When the anomaly started.""" + startTime: String + + """When the anomaly ended.""" + endTime: String + + """The metric value at the start of the anomaly.""" + startValue: Float + + """The metric value at the end of the anomaly.""" + endValue: Float + + """The anomaly detection algorithm version.""" + algorithmVersion: String + + """The contextual data attached to the anomaly.""" + data: AnomalyContextData +} + +"""A single threshold rule for a metric.""" +input MetricThresholdInput { + """The threshold value.""" + value: Float! + + """The comparison operator for the threshold.""" + operator: String! +} + +"""Input for setting or updating metric thresholds.""" +input SetOrUpdateMetricThresholdsInput { + """The environment ID the thresholds apply to.""" + envId: Int! + + """The metric name the thresholds apply to.""" + metricName: String! + + """The threshold rules to set.""" + thresholds: [MetricThresholdInput]! +} + +"""Input for deleting metric thresholds.""" +input DeleteMetricThresholdsInput { + """The environment ID the thresholds apply to.""" + envId: Int! + + """The metric name the thresholds apply to.""" + metricName: String! + + """The event type whose thresholds should be deleted.""" + eventType: String! +} + +"""A metric threshold configured for an environment.""" +type MetricThreshold { + """The unique identifier for the threshold.""" + id: Int! + + """The threshold value.""" + value: Float! + + """The comparison operator for the threshold.""" + operator: String! + + """The metric name the threshold applies to.""" + metricName: String! +} + +"""The result of deleting metric thresholds.""" +type DeleteMetricThresholdsPayload { + """Whether the delete operation succeeded.""" + success: Boolean! +} + +"""The result of setting or updating metric thresholds.""" +type SetOrUpdateMetricThresholdPayload { + """Whether the set or update operation succeeded.""" + success: Boolean! + + """The thresholds after the operation.""" + thresholds: [MetricThreshold] +} + +"""Pageview totals for an application or organization.""" +type Pageviews { + """The total number of pageviews.""" + total: BigInt + + """The number of static asset requests.""" + staticRequests: BigInt + + """The number of application requests.""" + appRequests: BigInt + + """The number of API requests.""" + apiRequests: BigInt + + """The start date for the pageview range.""" + startDate: String + + """The end date for the pageview range.""" + endDate: String + + """The pageview breakdown details.""" + details: [PageviewDetails] +} + +"""Pageview totals for a single time slice.""" +type PageviewDetails { + """The total number of pageviews in the slice.""" + total: Int + + """The number of static asset requests in the slice.""" + staticRequests: Int + + """The number of application requests in the slice.""" + appRequests: Int + + """The number of API requests in the slice.""" + apiRequests: Int + + """The start date for the slice.""" + startDate: String + + """The end date for the slice.""" + endDate: String +} + +"""A single metric measurement.""" +type MetricMeasurement { + """The timestamp for the measurement.""" + timestamp: String! + + """The measured value.""" + value: Float + + """The baseline value used for comparison.""" + baseline: Float + + """Whether the measurement is anomalous.""" + isAnomalous: Boolean + + """ + The breakdown bucket this measurement belongs to when the upstream aggregation groups by a dimension (e.g. 'human', 'ai_agent', 'crawler'). Null when the query was not grouped by a dimension. + """ + breakdown: String +} + +""" +A health score summary describing the qualitative state of an environment. +""" +type HealthScore { + """Numeric score (0–100). Higher is healthier.""" + score: Int + + """Human-readable explanation of why the environment received this score.""" + description: String +} + +""" +An overview of insights and metrics for an environment over a date range. Inner sections are returned as JSON to allow upstream evolution without schema churn. +""" +type EnvironmentInsightsOverview { + """ + Qualitative insight entries (positive/action items grouped by category). Each item is an object with at least `type`, `category`, `title`, and `description`. + """ + insights: JSON + + """ + Aggregated metric summaries with current vs previous period totals. Each item is an object with at least `metric`, `currTotalAggr`, `prevTotalAggr`, `aggrFunction`, and `measurementUnit`. + """ + metrics: JSON + + """Overall health score for the environment over the selected window.""" + healthScore: HealthScore +} + +"""Aggregated measurements for a metric query.""" +type AggregatedMetricMeasurements { + """The query identifier for the metric request.""" + queryId: String! + + """The metric name.""" + metricName: String! + + """The display name of the metric.""" + metricDisplayName: String + + """The resolution of the aggregated measurements.""" + resolution: Int + + """The aggregated total for the current period.""" + currTotalAggr: Float + + """The aggregated total for the previous period.""" + prevTotalAggr: Float + + """The aggregation function applied to the metric.""" + aggrFunction: String + + """The unit of measurement.""" + measurementUnit: String + + """The measurements returned for the query.""" + measurements: [MetricMeasurement]! +} + +"""Input for disabling New Relic on an environment.""" +input AppEnvironmentDisableNewRelicInput { + """The application ID that owns the environment.""" + appId: Int! + + """The environment ID to disable New Relic on.""" + environmentId: Int! +} + +"""The result of disabling New Relic.""" +type AppEnvironmentDisableNewRelicPayload { + """Whether New Relic was disabled successfully.""" + success: Boolean! +} + +"""Input for enabling New Relic on an environment.""" +input AppEnvironmentEnableNewRelicInput { + """The application ID that owns the environment.""" + appId: Int! + + """The environment ID to enable New Relic on.""" + environmentId: Int! +} + +"""The result of enabling New Relic.""" +type AppEnvironmentEnableNewRelicPayload { + """Whether New Relic was enabled successfully.""" + success: Boolean! +} + +"""Input for adding a New Relic user to an environment.""" +input AppEnvironmentAddNewRelicUserInput { + """The application ID that owns the environment.""" + appId: Int! + + """The environment ID to add the user to.""" + environmentId: Int! + + """The first name of the user to add.""" + firstName: String! + + """The last name of the user to add.""" + lastName: String! + + """The email address of the user to add.""" + email: String! +} + +"""The result of adding a New Relic user.""" +type AppEnvironmentAddNewRelicUserPayload { + """Whether the user was added successfully.""" + success: Boolean! +} + +"""Input for deleting a New Relic user from an environment.""" +input AppEnvironmentDeleteNewRelicUserInput { + """The application ID that owns the environment.""" + appId: Int! + + """The environment ID to remove the user from.""" + environmentId: Int! + + """The New Relic user ID to remove.""" + userId: Int! +} + +"""The result of deleting a New Relic user.""" +type AppEnvironmentDeleteNewRelicUserPayload { + """Whether the user was deleted successfully.""" + success: Boolean! +} + +"""Input for listing New Relic configuration on an environment.""" +input AppEnvironmentListNewRelicInput { + """The application ID that owns the environment.""" + appId: Int! + + """The environment ID to inspect.""" + environmentId: Int! +} + +"""A New Relic user.""" +interface NewRelicUser { + """The unique identifier for the user.""" + id: Int! + + """The email address of the user.""" + email: String! + + """The display name of the user.""" + name: String! +} + +"""A list of New Relic users.""" +type NewRelicUserList { + """The total number of users.""" + total: BigInt! + + """The users returned in the current page.""" + nodes: [NewRelicUser]! + + """The cursor for the next page of users.""" + nextCursor: String +} + +"""Input for deleting a notification subscription.""" +input DeleteNotificationSubscriptionInput { + """The notification subscription ID to delete.""" + notificationSubscriptionId: Int! +} + +"""The result of deleting a notification subscription.""" +type DeleteNotificationSubscriptionPayload { + """Whether the notification subscription was deleted.""" + deleted: Boolean +} + +"""Supported webhook payload versions.""" +enum NotificationWebhookVersion { + """The legacy webhook payload format.""" + v0 + + """The current webhook payload format.""" + v1 +} + +"""Supported notification recipient channels.""" +enum NotificationRecipientType { + """Deliver notifications by email.""" + EMAIL + + """Deliver notifications to a Slack webhook.""" + SLACK + + """Deliver notifications to a generic webhook.""" + WEBHOOK + + """Deliver notifications to a Google Chat webhook.""" + GOOGLE_CHAT + + """Deliver notifications to a Microsoft Teams webhook.""" + MICROSOFT_TEAMS +} + +"""Additional configuration for a notification recipient.""" +input NotificationRecipientMetaInput { + """The webhook payload version to send for webhook recipients.""" + webhookVersion: NotificationWebhookVersion +} + +"""A notification recipient that can receive subscribed notifications.""" +interface NotificationRecipient { + """The unique identifier for the recipient.""" + id: Int! + + """The organization that owns the recipient.""" + organizationId: Int! + + """Notes describing the recipient.""" + description: String + + """The display name for the recipient.""" + name: String + + """The delivery channel used by the recipient.""" + recipientType: NotificationRecipientType + + """The destination value, such as an email address or webhook URL.""" + recipientValue: String + + """When the recipient was created.""" + createdAt: Date + + """When the recipient was last updated.""" + updatedAt: Date + + """The notification subscriptions attached to this recipient.""" + notificationSubscriptions: NotificationSubscriptionList +} + +"""Webhook-specific metadata for a notification recipient.""" +type WebhookRecipientMeta { + """The webhook payload version configured for the recipient.""" + webhookVersion: String + + """The last response body returned by the webhook endpoint.""" + lastResponse: String + + """The last HTTP status code returned by the webhook endpoint.""" + lastResponseCode: Int + + """When the webhook endpoint last responded.""" + lastResponseTime: Date +} + +"""A webhook-based notification recipient.""" +type WebhookNotificationRecipient implements NotificationRecipient { + """The unique identifier for the recipient.""" + id: Int! + + """The organization that owns the recipient.""" + organizationId: Int! + + """Notes describing the recipient.""" + description: String + + """The display name for the recipient.""" + name: String + + """The delivery channel used by the recipient.""" + recipientType: NotificationRecipientType + + """The webhook URL that receives notifications.""" + recipientValue: String + + """When the recipient was created.""" + createdAt: Date + + """When the recipient was last updated.""" + updatedAt: Date + + """Webhook-specific metadata for the recipient.""" + meta: WebhookRecipientMeta + + """The notification subscriptions attached to this recipient.""" + notificationSubscriptions: NotificationSubscriptionList +} + +"""A Slack webhook notification recipient.""" +type SlackNotificationRecipient implements NotificationRecipient { + """The unique identifier for the recipient.""" + id: Int! + + """The organization that owns the recipient.""" + organizationId: Int! + + """Notes describing the recipient.""" + description: String + + """The display name for the recipient.""" + name: String + + """The delivery channel used by the recipient.""" + recipientType: NotificationRecipientType + + """The Slack webhook URL that receives notifications.""" + recipientValue: String + + """When the recipient was created.""" + createdAt: Date + + """When the recipient was last updated.""" + updatedAt: Date + + """The notification subscriptions attached to this recipient.""" + notificationSubscriptions: NotificationSubscriptionList +} + +"""A Google Chat webhook notification recipient.""" +type GoogleChatNotificationRecipient implements NotificationRecipient { + """The unique identifier for the recipient.""" + id: Int! + + """The organization that owns the recipient.""" + organizationId: Int! + + """Notes describing the recipient.""" + description: String + + """The display name for the recipient.""" + name: String + + """The delivery channel used by the recipient.""" + recipientType: NotificationRecipientType + + """The Google Chat webhook URL that receives notifications.""" + recipientValue: String + + """When the recipient was created.""" + createdAt: Date + + """When the recipient was last updated.""" + updatedAt: Date + + """The notification subscriptions attached to this recipient.""" + notificationSubscriptions: NotificationSubscriptionList +} + +"""A Microsoft Teams webhook notification recipient.""" +type MicrosoftTeamsNotificationRecipient implements NotificationRecipient { + """The unique identifier for the recipient.""" + id: Int! + + """The organization that owns the recipient.""" + organizationId: Int! + + """Notes describing the recipient.""" + description: String + + """The display name for the recipient.""" + name: String + + """The delivery channel used by the recipient.""" + recipientType: NotificationRecipientType + + """The Microsoft Teams webhook URL that receives notifications.""" + recipientValue: String + + """When the recipient was created.""" + createdAt: Date + + """When the recipient was last updated.""" + updatedAt: Date + + """The notification subscriptions attached to this recipient.""" + notificationSubscriptions: NotificationSubscriptionList +} + +"""An email notification recipient.""" +type EmailNotificationRecipient implements NotificationRecipient { + """The unique identifier for the recipient.""" + id: Int! + + """The organization that owns the recipient.""" + organizationId: Int! + + """Notes describing the recipient.""" + description: String + + """The display name for the recipient.""" + name: String + + """The delivery channel used by the recipient.""" + recipientType: NotificationRecipientType + + """The email address that receives notifications.""" + recipientValue: String + + """When the recipient was created.""" + createdAt: Date + + """When the recipient was last updated.""" + updatedAt: Date + + """The notification subscriptions attached to this recipient.""" + notificationSubscriptions: NotificationSubscriptionList +} + +"""A paginated list of notification recipients.""" +type NotificationRecipientList { + """The total number of recipients in the result set.""" + total: BigInt! + + """The recipients in the current page.""" + nodes: [NotificationRecipient]! + + """The cursor for the next page of recipients.""" + nextCursor: String +} + +"""Input for creating a notification recipient.""" +input AddNotificationRecipientInput { + """The organization that will own the recipient.""" + organizationId: BigInt! + + """ + The application ID used to scope access checks when creating the recipient. + """ + appId: BigInt + + """The display name for the recipient.""" + name: String! + + """Notes describing the recipient.""" + description: String + + """Additional configuration for the recipient.""" + meta: NotificationRecipientMetaInput + + """The delivery channel to configure.""" + recipientType: NotificationRecipientType! + + """The destination value, such as an email address or webhook URL.""" + recipientValue: String! +} + +"""The result of creating a notification recipient.""" +type AddNotificationRecipientPayload { + """The created notification recipient.""" + notificationRecipient: NotificationRecipient +} + +"""Input for updating a notification recipient.""" +input UpdateNotificationRecipientInput { + """The recipient ID to update.""" + id: Int! + + """The organization that owns the recipient.""" + organizationId: Int! + + """Whether the recipient should be marked active.""" + active: Boolean + + """Notes describing the recipient.""" + description: String + + """Additional configuration for the recipient.""" + meta: NotificationRecipientMetaInput + + """The display name for the recipient.""" + name: String + + """The delivery channel to configure.""" + recipientType: NotificationRecipientType + + """The destination value, such as an email address or webhook URL.""" + recipientValue: String +} + +"""The result of updating a notification recipient.""" +type UpdateNotificationRecipientPayload { + """The updated notification recipient.""" + notificationRecipient: NotificationRecipient +} + +"""Additional metadata for a notification subscription.""" +type NotificationSubscriptionMeta { + """The event types that trigger the subscription.""" + eventTypes: [String!] +} + +"""Input for notification subscription metadata.""" +input NotificationSubscriptionMetaInput { + """The event types that trigger the subscription.""" + eventTypes: [String!] +} + +"""A notification subscription that links a recipient to a target entity.""" +type NotificationSubscription { + """The unique identifier for the subscription.""" + id: Int! + + """Whether the subscription is active.""" + active: Boolean + + """Notes describing the subscription.""" + description: String + + """The entity type the subscription applies to.""" + entityType: String! + + """The entity identifier or pattern the subscription applies to.""" + entityValue: String! + + """ + The application associated with the subscription target, when available. + """ + application: App + + """Additional metadata for the subscription.""" + meta: NotificationSubscriptionMeta + + """Whether the subscription is for Very Important Notifications.""" + vin: Boolean + + """The recipient that receives notifications for this subscription.""" + notificationRecipient: NotificationRecipient + + """When the subscription was created.""" + createdAt: Date + + """When the subscription was last updated.""" + updatedAt: Date +} + +"""A paginated list of notification subscriptions.""" +type NotificationSubscriptionList { + """The total number of subscriptions in the result set.""" + total: BigInt! + + """The subscriptions in the current page.""" + nodes: [NotificationSubscription]! + + """The cursor for the next page of subscriptions.""" + nextCursor: String +} + +"""Input for deleting a notification recipient.""" +input DeleteNotificationRecipientInput { + """The organization that owns the recipient.""" + organizationId: Int! + + """The notification recipient ID to delete.""" + notificationRecipientId: Int! +} + +"""The result of deleting a notification recipient.""" +type DeleteNotificationRecipientPayload { + """Whether the notification recipient was deleted.""" + deleted: Boolean +} + +"""Input for creating a notification subscription.""" +input AddNotificationSubscriptionInput { + """The recipient that should receive notifications.""" + notificationRecipientId: BigInt! + + """The organization that owns the recipient and subscription.""" + organizationId: BigInt! + + """Notes describing the subscription.""" + description: String! + + """Whether the subscription should be active.""" + active: Boolean + + """Additional metadata for the subscription.""" + meta: NotificationSubscriptionMetaInput + + """Whether the subscription is for Very Important Notifications.""" + vin: Boolean + + """The entity type the subscription applies to.""" + entityType: String! + + """The entity identifier or pattern the subscription applies to.""" + entityValue: String! +} + +"""The result of creating a notification subscription.""" +type AddNotificationSubscriptionPayload { + """The created notification subscription.""" + notificationSubscription: NotificationSubscription +} + +"""Input for updating a notification subscription.""" +input UpdateNotificationSubscriptionInput { + """The subscription ID to update.""" + notificationSubscriptionId: Int! + + """The recipient that should receive notifications after the update.""" + notificationRecipientId: Int + + """Notes describing the subscription.""" + description: String + + """Whether the subscription should be active.""" + active: Boolean + + """Additional metadata for the subscription.""" + meta: NotificationSubscriptionMetaInput + + """Whether the subscription is for Very Important Notifications.""" + vin: Boolean + + """The entity type the subscription applies to.""" + entityType: String + + """The entity identifier or pattern the subscription applies to.""" + entityValue: String +} + +"""The result of updating a notification subscription.""" +type UpdateNotificationSubscriptionPayload { + """The updated notification subscription.""" + notificationSubscription: NotificationSubscription +} + +"""Input for sending a test notification to a recipient.""" +input SendTestNotificationInput { + """The recipient that should receive the test notification.""" + notificationRecipientId: Int! + + """The organization that owns the recipient.""" + organizationId: Int! + + """The optional header to include in the test notification.""" + header: String + + """The optional body to include in the test notification.""" + body: String +} + +"""The result of sending a test notification.""" +type SendTestNotificationPayload { + """Whether the test notification was sent successfully.""" + sent: Boolean +} + +"""Authentication methods that can be used to access VIP.""" +enum UserAuthMethod { + """Sign in with WordPress.com SSO.""" + wpcom + + """Sign in with GitHub SSO.""" + github + + """Sign in with a non-organization SSO provider.""" + other_sso + + """Sign in with an organization-managed identity provider.""" + organization_sso + + """Access restricted by organization SSO enforcement.""" + restricted +} + +""" +An organization that owns applications and users in WordPress VIP. This is the primary entry point for organization-scoped app, user, and event traversal. +""" +type Organization implements Model { + """The unique identifier for the organization.""" + id: Int + + """The display name of the organization.""" + name: String + + """The Salesforce account identifier for the organization.""" + salesforceId: String + + """The URL-friendly slug for the organization.""" + slug: String + + """Whether the organization is part of a FedRAMP environment.""" + isFedramp: Boolean + + """ + Whether the organization has a signed BAA and must follow HIPAA requirements. + """ + isHipaa: Boolean + + """The current service status for the organization.""" + serviceStatus: String + + """ + The applications that belong to the organization. Returns an AppList with `total`, `nextCursor`, and `nodes`. + """ + apps( + """The maximum number of applications to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """Filter applications by active state.""" + active: String + + """The free-text filter to match against applications.""" + matching: String + + """Filter applications by application type IDs.""" + appType: [Int] + + """The page number to fetch.""" + page: Int + ): AppList + + """The VIP support package assigned to the organization.""" + supportPackage: String + + """ + Whether Let's Encrypt certificates are disallowed for the organization. + """ + letsEncryptDisallowed: Boolean + + """The inactivity threshold, in days, used for organization users.""" + considerUsersInactiveAfterDays: Int + + """Whether organization SSO access enforcement is enabled.""" + enforceSSOAccess: Boolean + + """The traffic unit used for organization limits and reporting.""" + trafficType: TrafficType + + """The traffic allocation or limit for the organization.""" + traffic: Int + + """The organization contacts grouped by role.""" + contacts: OrganizationContacts + + """The invitations sent for the organization.""" + invitations( + """The maximum number of invitations to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """The page number to fetch.""" + page: Int + + """Filter invitations by status.""" + status: String + + """The free-text filter to match against invitations.""" + matching: String + ): InvitationList + + """The notification recipients configured for the organization.""" + notificationRecipients( + """The application ID used to scope app-role permission checks.""" + appId: Int + + """The maximum number of recipients to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """The free-text filter to match against recipients.""" + matching: String + ): NotificationRecipientList + + """The notification subscriptions configured for the organization.""" + notificationSubscriptions( + """The maximum number of subscriptions to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """Filter subscriptions by active status.""" + active: Boolean + + """Filter subscriptions for a specific notification recipient.""" + notificationRecipientId: Int + + """Filter subscriptions by their VIN flag.""" + vin: Boolean + ): NotificationSubscriptionList + + """A single notification subscription on the organization.""" + notificationSubscription( + """The notification subscription ID.""" + id: Int! + ): NotificationSubscription + + """The Salesforce plan associated with the organization.""" + plan: OrganizationPlan + + """The Salesforce subscriptions matching a supported product code filter.""" + subscriptions( + """ + The Salesforce subscription code to query, such as `ADDINSTALL` or `BASENONPRODENV`. + """ + search: String! + ): [SalesforceSubscription] + + """Pageview metrics for the organization.""" + pageviews: Pageviews + + """Request statistics for the organization.""" + requestStats( + """The start of the reporting window.""" + from: Date + + """The end of the reporting window.""" + to: Date + ): OrgRequestStatsList + + """Visitor statistics for the organization.""" + visitorsStats( + """The number of days to include in the reporting window.""" + days: Int + + """The start of the reporting window.""" + from: Date + + """The end of the reporting window.""" + to: Date + ): VisitorsStatsList + + """ + The users that belong to the organization. Supports both cursor pagination (`after`) and a legacy page-number argument (`page`). + """ + users( + """Filter users by VIP status.""" + isVIP: Boolean + + """Filter users by ID.""" + id: Int + + """The maximum number of users to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """The page number to fetch.""" + page: Int + + """Filter for external users.""" + externalUsers: Boolean + + """Filter users by authentication method.""" + authMethod: UserAuthMethod + ): UserList + + """The identity providers configured for the organization.""" + identityProviders( + """The identity provider ID to filter by.""" + id: Int + ): IdentityProviderList + + """ + The audit events recorded for the organization. Returns an AuditEventList with cursor pagination metadata. + """ + events( + """The pagination cursor to continue from.""" + after: String + + """The maximum number of events to return.""" + first: Int + + """The sort order to apply.""" + order: String + + """Whether to exclude anomaly events from the results.""" + excludeAnomalyEvents: Boolean + ): AuditEventList + + """Permission checks for the current user on this organization.""" + permissions( + """The permission keys to evaluate.""" + permissions: [String] + ): [PermissionResult] + + """The auth domains configured for the organization.""" + authDomains( + """The exact auth domain to filter by.""" + domain: String + ): OrganizationAuthDomainList + + """The organization-level feature flags.""" + features: [OrganizationFeature] + + """The integrations configured for the organization.""" + integrations: IntegrationClientList + + """A single integration configured for the organization.""" + integration( + """The integration slug.""" + slug: String! + ): Integration +} + +"""An organization-level feature flag.""" +type OrganizationFeature { + """The feature flag slug.""" + slug: String + + """Whether the feature is enabled.""" + enabled: Boolean +} + +"""The traffic units used for organization plans and reporting.""" +enum TrafficType { + """Traffic measured in monthly unique visitors.""" + MUV + + """Traffic measured in HTTP requests.""" + HTTP +} + +"""The Salesforce-backed plan details for an organization.""" +type OrganizationPlan { + """The plan name.""" + planName: String + + """The plan start date.""" + planStartDate: String + + """The plan end date.""" + planEndDate: String + + """The number of requests included in the plan.""" + planIncludedRequests: Int + + """The support ticket SLA for the plan.""" + ticketSLA: String + + """The uptime SLA for the plan.""" + uptimeSLA: String + + """The number of applications allowed by the plan.""" + numberOfAllowedApplications: Int + + """The number of non-production environments allowed by the plan.""" + numberOfAllowedNonProdEnvironments: Int + + """The code review service level for the plan.""" + codeReviewLevel: String + + """The add-ons included with the plan.""" + addOns: [String] + + """The traffic unit used by the plan.""" + trafficType: TrafficType + + """The traffic allocation included in the plan.""" + traffic: Int +} + +"""A Salesforce subscription associated with an organization.""" +type SalesforceSubscription { + """The Salesforce product code.""" + productCode: String + + """The Salesforce product family.""" + productFamily: String + + """The Salesforce product name.""" + productName: String + + """The Salesforce product type.""" + productType: String + + """The subscribed quantity.""" + quantity: Int + + """The subscription start date.""" + startDate: String + + """The subscription end date.""" + endDate: String + + """The related application ID, when applicable.""" + applicationId: Int +} + +"""The primary contact groups for an organization.""" +type OrganizationContacts { + """The account owner contacts.""" + accountOwners: OrganizationContactList + + """The support contacts.""" + supportContacts: OrganizationContactList + + """The technical contacts.""" + technicalContacts: OrganizationContactList + + """The VIP relationship manager contact.""" + vipRelationshipManager: OrganizationContact + + """The VIP technical account manager contact.""" + vipTechnicalAccountManager: OrganizationContact + + """The VIP launch TAM contact.""" + vipLaunchTAM: OrganizationContact +} + +"""A list of organization contacts.""" +type OrganizationContactList { + """The total number of contacts in the list.""" + total: Int + + """The contacts in the list.""" + nodes: [OrganizationContact] +} + +"""A contact associated with an organization.""" +type OrganizationContact { + """The contact name.""" + name: String + + """The contact job title.""" + title: String + + """The contact type.""" + type: String + + """The contact email address.""" + email: String +} + +"""A paginated list of organizations.""" +type OrgList implements ModelList { + """The total number of matching organizations.""" + total: Int + + """The cursor for the next page of organizations.""" + nextCursor: String + + """The organizations returned in the current page.""" + nodes: [Organization] + + """A legacy alias for `nodes`.""" + edges: [Organization] +} + +"""The result of checking a permission for the current user.""" +type PermissionResult { + """The permission key that was evaluated.""" + permission: String + + """Whether the permission is allowed.""" + isAllowed: Boolean +} + +""" +Input for generating a Google Sheets access token from service account credentials. +""" +input GenerateGoogleSheetsAccessTokenInput { + """ + The Google service account credentials to exchange for an access token. + """ + credentials: GoogleSheetsCredentialsInput! +} + +"""Google service account credentials for accessing Google Sheets.""" +input GoogleSheetsCredentialsInput { + """The credential type.""" + type: String! + + """The Google Cloud project ID.""" + project_id: String! + + """The private key ID.""" + private_key_id: String! + + """The private key.""" + private_key: String! + + """The service account email.""" + client_email: String! + + """The service account client ID.""" + client_id: String! + + """The OAuth authorization URI.""" + auth_uri: String! + + """The OAuth token URI.""" + token_uri: String! + + """The auth provider certificate URL.""" + auth_provider_x509_cert_url: String! + + """The client certificate URL.""" + client_x509_cert_url: String! + + """The Google API universe domain.""" + universe_domain: String! +} + +"""The result of generating a Google Sheets access token.""" +type GenerateGoogleSheetsAccessTokenPayload { + """The generated Google access token.""" + accessToken: String! + + """When the access token expires.""" + expiresAt: BigInt +} + +"""A source code repository connected to WordPress VIP.""" +type Repo implements Model { + """The unique identifier for the repository record.""" + id: Int + + """The repository name in `owner/name` format.""" + name: String + + """The default or selected branch for the repository.""" + branch: String + + """The applications linked to this repository.""" + apps: AppList +} + +"""A review queue of repositories that need attention.""" +type ReviewQueue { + """The repositories currently in the review queue.""" + repos: [Repo] +} + +"""Input for rolling an environment back to a previous deployment.""" +input RollbackInput { + """The application ID that owns the environment.""" + appId: Int + + """The environment ID to roll back.""" + environmentId: Int + + """The deployment ID to roll back to.""" + toDeploymentId: Int +} + +"""The result of a rollback request.""" +type RollbackPayload { + """The deployment created by the rollback.""" + newDeployment: Deployment +} + +"""Certificate signing request information.""" +input CSRInfo { + """The certificate common name.""" + commonName: String! + + """The alternative names to include in the certificate.""" + altNames: [String] + + """The country code for the certificate subject.""" + country: String! + + """The state or region for the certificate subject.""" + state: String! + + """The locality or city for the certificate subject.""" + locality: String! + + """The organization for the certificate subject.""" + organization: String! + + """The organizational unit for the certificate subject.""" + organizationUnit: String + + """The email address for the certificate subject.""" + emailAddress: String +} + +"""Input for creating a CSR.""" +input CreateCSRInput { + """The client ID that owns the certificate.""" + clientId: Int! + + """The domain name for the certificate.""" + domainName: String + + """The CSR details to generate.""" + csr: CSRInfo! +} + +"""The result of creating a CSR.""" +type CreateCSRPayload { + """The generated certificate ID.""" + certificateId: Int +} + +"""Input for adding a certificate.""" +input AddCertificateInput { + """The client ID that owns the certificate.""" + clientId: Int! + + """The domain name for the certificate.""" + domainName: String + + """The CSR string.""" + csr: String! + + """The private key for the certificate.""" + key: String! + + """The certificate body.""" + certificate: String! + + """The trusted certificate chain.""" + trustedCertificate: String +} + +"""The result of adding a certificate.""" +type AddCertificatePayload { + """The created certificate ID.""" + certificateId: Int + + """The created certificate.""" + certificate: String +} + +"""Input for activating a certificate on domains.""" +input ActivateCertificateInput { + """The domain names to activate the certificate on.""" + domainNames: [String] + + """The certificate ID to activate.""" + certificateId: Int +} + +"""The result of activating a certificate.""" +type ActivateCertificatePayload { + """The activated certificate ID.""" + certificateId: Int +} + +"""A decoded certificate signing request.""" +type CSRDecoded { + """The decoded common name.""" + commonName: String + + """The decoded alternative names.""" + altNames: [String] + + """The decoded country code.""" + country: String + + """The decoded state or region.""" + state: String + + """The decoded locality or city.""" + locality: String + + """The decoded organization.""" + organization: String + + """The decoded organizational unit.""" + organizationUnit: String + + """The decoded email address.""" + emailAddress: String +} + +"""Issuer details for a certificate.""" +type CertificateIssuer { + """The issuer country code.""" + country: String + + """The issuer organization.""" + organization: String + + """The issuer common name.""" + commonName: String +} + +"""A TLS certificate.""" +type Certificate { + """The certificate identifier.""" + certificateId: Int + + """Domain name. Ex: www.example.com""" + commonName: String + + """OpenSSL generated CSR string""" + csr: String + + """The decoded CSR details.""" + csrDecoded: CSRDecoded + + """Alternative names""" + san: [String] + + """Whether the certificate is active.""" + active: Boolean + + """Whether a certificate body is present.""" + hasCertificate: Boolean + + """When the certificate validity begins.""" + beginsTimestamp: String + + """When the certificate expires.""" + expiresTimestamp: String + + """When the certificate record was created.""" + created: String + + """The issuer details for the certificate.""" + issuer: CertificateIssuer + + """Whether the certificate is currently valid.""" + valid: Boolean +} + +"""A paginated list of certificates.""" +type CertificateList { + """The total number of matching certificates.""" + total: Int + + """The cursor for the next page of certificates.""" + nextCursor: String + + """The certificates returned in the current page.""" + nodes: [Certificate] +} + +"""Input for updating a certificate.""" +input UpdateCertificateInput { + """The client ID that owns the certificate.""" + clientId: Int! + + """The domain name for the certificate.""" + domainName: String + + """The certificate ID to update.""" + certificateId: Int! + + """The replacement certificate body.""" + certificate: String! + + """The replacement trusted certificate chain.""" + trustedCertificate: String +} + +"""The result of updating a certificate.""" +type UpdateCertificatePayload { + """The updated certificate.""" + certificate: Certificate +} + +"""Input for decoding a CSR.""" +input DecodeCSRInput { + """The CSR string to decode.""" + csr: String! +} + +"""Input for deleting a certificate.""" +input DeleteCertificateInput { + """The domain name associated with the certificate.""" + domainName: String! + + """The certificate ID to delete.""" + certificateId: Int! +} + +"""The result of deleting a certificate.""" +type DeleteCertificatePayload { + """Whether the certificate was deleted.""" + deleted: Boolean +} + +"""A purpose-specific token issued to a user.""" +type Token implements Model { + """The unique identifier for the token.""" + id: Int + + """The user ID that owns the token.""" + userId: Int + + """The token expiration time as a Unix timestamp.""" + exp: Int + + """Whether the token is active.""" + active: Boolean + + """Whether the token was disabled due to inactivity.""" + disabledDueToInactivity: Boolean + + """When the token was created.""" + createdAt: Date + + """When the token was last used.""" + lastUsedAt: Date + + """When the token expires.""" + expiresAt: Date + + """The purpose of the token.""" + purpose: String + + """The environment IDs associated with the token.""" + environmentIds: [Int] +} + +"""Input for deactivating a purpose token.""" +input DeactivatePurposeTokenInput { + """The token ID to deactivate.""" + id: Int! + + """The purpose of the token to deactivate.""" + purpose: String! +} + +"""Input for generating an email verification token.""" +input GenerateEmailVerificationTokenInput { + """The email address to verify.""" + email: String! +} + +"""The result of deactivating a purpose token.""" +type DeactivatePurposeTokenPayload { + """Whether the token was deactivated.""" + success: Boolean +} + +"""The result of generating an email verification token.""" +type EmailVerificationTokenPayload { + """Whether the token was generated successfully.""" + success: Boolean + + """When the generated token expires.""" + expiresAt: Date + + """The email address associated with the token.""" + email: String +} + +"""Input for validating an email verification token.""" +input ValidateEmailVerificationTokenInput { + """The email verification token to validate.""" + token: String! +} + +"""The result of validating an email verification token.""" +type ValidateEmailVerificationTokenPayload { + """The email address associated with the token.""" + email: String + + """Whether the token is valid.""" + success: Boolean +} + +"""Input for cancelling a pending email verification token.""" +input CancelEmailVerificationTokenInput { + """The token ID to cancel.""" + id: Int +} + +"""The result of cancelling a pending email verification token.""" +type CancelPendingEmailVerificationTokenPayload { + """Whether a pending token was cancelled.""" + success: Boolean + + """The token that was cancelled.""" + cancelledToken: Token +} + +"""A paginated list of purpose tokens.""" +type TokenList implements ModelList { + """The cursor for the next page of tokens.""" + nextCursor: String + + """The tokens returned in the current page.""" + nodes: [Token!]! + + """The total number of matching tokens.""" + total: Int! +} + +"""A user in WordPress VIP.""" +type User implements Model { + """The unique identifier for the user.""" + id: Int + + """The display name for the user.""" + displayName: String + + """The primary email address for the user.""" + emailAddress: String + + """Whether the user's primary email address is verified.""" + isEmailVerified: Boolean + + """Whether the user still has a legacy unverified email state.""" + isEmailLegacyUnverified: Boolean + + """The user's GitHub username.""" + githubUsername: String + + """The user's WordPress.com username.""" + wpcomUsername: String + + """Whether the user currently has VIP access.""" + isVIP: Boolean + + """The Auth0 identifier for the user.""" + auth0Id: String + + """The VIP Auth identifier for the user.""" + vipAuthId: String + + """Whether the user signs in through VIP Auth.""" + isVipAuthUser: Boolean + + """The configured MFA methods for the user.""" + mfaMethods: MfaMethods + + """The SAML NameID from the user's current SSO identity.""" + samlNameId: String + + """The internal tracking identifier used for analytics and debug tooling.""" + trackingUserId: String + + """The active tokens for the user.""" + tokens: [Token] + + """The organization roles assigned to the user.""" + organizationRoles( + """The organization ID to filter by.""" + organizationId: Int + + """The organization role ID to filter by.""" + roleId: String + ): UserOrganizationRoleList + + """The application roles assigned to the user.""" + applicationRoles( + """The organization ID used to scope application roles.""" + organizationId: Int + + """The application ID to filter by.""" + appId: Int + ): UserApplicationRoleList + + """When the user was last seen in the current organization context.""" + lastSeenAt: Date + + """ + The organization ID associated with the SAML identity provider used for login. + """ + samlOrganizationId: Int + + """The name of the SAML identity provider used for login.""" + samlIdentityProviderName: String + + """The authentication method used for the current session.""" + authMethod: String + + """ + Whether the user is considered inactive in the current organization context. + """ + isConsideredInactive: Boolean + + """The latest email verification token data for the user.""" + emailVerification: EmailVerificationTokenData +} + +"""The currently authenticated user.""" +type Me { + """The unique identifier for the current user.""" + id: Int + + """The display name for the current user.""" + displayName: String + + """The primary email address for the current user.""" + emailAddress: String + + """Whether the current user's primary email address is verified.""" + isEmailVerified: Boolean + + """Whether the current user still has a legacy unverified email state.""" + isEmailLegacyUnverified: Boolean + + """The current user's GitHub username.""" + githubUsername: String + + """The current user's WordPress.com username.""" + wpcomUsername: String + + """Whether the current user currently has VIP access.""" + isVIP: Boolean + + """The Auth0 identifier for the current user.""" + auth0Id: String + + """The VIP Auth identifier for the current user.""" + vipAuthId: String + + """Whether the current user signs in through VIP Auth.""" + isVipAuthUser: Boolean + + """The configured MFA methods for the current user.""" + mfaMethods: MfaMethods + + """The SAML NameID from the current user's SSO identity.""" + samlNameId: String + + """The internal tracking identifier used for analytics and debug tooling.""" + trackingUserId: String + + """The active tokens for the current user.""" + tokens: [Token] + + """The organization roles assigned to the current user.""" + organizationRoles( + """The organization ID to filter by.""" + organizationId: Int + ): UserOrganizationRoleList + + """The application roles assigned to the current user.""" + applicationRoles( + """The organization ID used to scope application roles.""" + organizationId: Int + + """The application ID to filter by.""" + appId: Int + ): UserApplicationRoleList + + """ + When the current user was last seen in the current organization context. + """ + lastSeenAt: Date + + """ + The organization ID associated with the SAML identity provider used for login. + """ + samlOrganizationId: Int + + """The name of the SAML identity provider used for login.""" + samlIdentityProviderName: String + + """The authentication method used for the current session.""" + authMethod: String + + """ + Whether the current user is considered inactive in the current organization context. + """ + isConsideredInactive: Boolean + + """ + Whether the current user would be VIP before proxy-based checks are applied. + """ + shouldBeVIP: Boolean + + """The latest email verification token data for the current user.""" + emailVerification: EmailVerificationTokenData + + """The IP address of the current request.""" + currentIP: String +} + +"""The MFA methods available to a user.""" +type MfaMethods { + """The user's preferred MFA method.""" + preferredMethod: String + + """The MFA methods configured for the user.""" + configuredMethods: [String] +} + +"""Input for generating a user token.""" +input UserTokenGenerationInput { + """The requested token lifetime, up to one year.""" + lifetime: String +} + +"""The result of generating a user token.""" +type UserTokenGenerationPayload { + """The generated JWT.""" + jwt: String +} + +"""Input for creating a user.""" +input CreateUserInput { + """The GitHub username for the user to create.""" + githubUsername: String! + + """Whether the new user should be granted VIP access.""" + isVIP: Boolean +} + +"""The result of creating a user.""" +type CreateUserPayload { + """The created user.""" + user: User +} + +"""Input for updating a user.""" +input UpdateUserInput { + """The user ID to update.""" + userId: Int! + + """The GitHub username to set.""" + githubUsername: String + + """The email address to set.""" + emailAddress: String + + """The display name to set.""" + displayName: String +} + +"""The result of updating a user.""" +type UpdateUserPayload { + """The updated user.""" + user: User +} + +"""Input for updating a user's organization role.""" +input UpdateUserOrganizationRoleInput { + """The user ID to update.""" + userId: Int! + + """The organization ID that owns the role.""" + organizationId: Int! + + """The organization role ID to assign.""" + role: String +} + +"""The result of updating a user's organization role.""" +type UpdateUserOrganizationRolePayload { + """The updated user.""" + user: User + + """The updated organization role.""" + organizationRole: UserOrganizationRole +} + +"""Input for removing a user from an organization.""" +input RemoveUserFromOrganizationInput { + """The user ID to remove.""" + userId: Int! + + """The organization ID to remove the user from.""" + organizationId: Int! +} + +"""The result of removing a user from an organization.""" +type RemoveUserFromOrganizationPayload { + """The affected user.""" + user: User +} + +"""Input for deactivating a user token.""" +input DeactivateUserTokenInput { + """The token ID to deactivate.""" + tokenId: Int! +} + +"""The result of deactivating a user token.""" +type DeactivateUserTokenPayload { + """Whether the token was deactivated successfully.""" + success: Boolean +} + +"""The current status of an email verification token.""" +enum EmailVerificationStatus { + """The token was used to verify the email address.""" + VERIFIED + + """The token was canceled.""" + CANCELED + + """The token expired before it was used.""" + EXPIRED + + """The token is still pending verification.""" + PENDING + + """The email address is unverified.""" + UNVERIFIED + + """The email address is in the legacy unverified state.""" + LEGACY_UNVERIFIED +} + +"""The latest email verification token data for a user.""" +type EmailVerificationTokenData { + """The email address being verified.""" + email: String + + """The current status of the latest verification token.""" + status: EmailVerificationStatus + + """When the latest verification token expires.""" + expires: Date +} + +"""A paginated list of users.""" +type UserList implements ModelList { + """The total number of matching users.""" + total: Int + + """The cursor for the next page of users.""" + nextCursor: String + + """The users returned in the current page.""" + nodes: [User] + + """A legacy alias for `nodes`.""" + edges: [User] +} + +"""An application role assigned to a user.""" +type UserApplicationRole implements Model { + """The unique identifier for the role assignment.""" + id: Int + + """The user ID that holds the role.""" + userId: Int + + """The application ID the role applies to.""" + appId: Int + + """The application the role applies to.""" + app: App + + """The role ID assigned to the user.""" + roleId: ApplicationRoleId + + """The role definition assigned to the user.""" + role: ApplicationRole + + """The source of the role assignment.""" + source: String +} + +"""An application role definition.""" +type ApplicationRole { + """The role name.""" + name: String + + """The parent role this role extends, if any.""" + extends: String +} + +"""The available application role IDs.""" +enum ApplicationRoleId { + """Application administrator.""" + admin + + """Application contributor with write access.""" + write + + """Application viewer with read access.""" + read +} + +"""A paginated list of user application roles.""" +type UserApplicationRoleList implements ModelList { + """The total number of matching role assignments.""" + total: Int + + """The cursor for the next page of role assignments.""" + nextCursor: String + + """The role assignments returned in the current page.""" + nodes: [UserApplicationRole] + + """A legacy alias for `nodes`.""" + edges: [UserApplicationRole] +} + +"""A single application role assignment for a user.""" +input UserApplicationRoleInput { + """The user ID that should receive the role.""" + userId: Int! + + """The application ID the role applies to.""" + appId: Int! + + """The application role ID to assign.""" + roleId: ApplicationRoleId +} + +"""Input for replacing a user's application role assignments.""" +input SetUserApplicationRolesInput { + """The application roles to assign to the user.""" + applicationRoles: [UserApplicationRoleInput]! +} + +"""The result of updating a user's application roles.""" +type SetUserApplicationRolesPayload { + """The application roles after the update.""" + applicationRoles: [UserApplicationRole] +} + +"""An organization role assigned to a user.""" +type UserOrganizationRole implements Model { + """The unique identifier for the role assignment.""" + id: Int + + """The user ID that holds the role.""" + userId: Int + + """The organization the role applies to.""" + organization: Organization + + """The organization ID the role applies to.""" + organizationId: Int + + """The role ID assigned to the user.""" + roleId: OrgRoleId + + """The role definition assigned to the user.""" + role: OrgRole + + """The source of the role assignment.""" + source: String + + """Whether the role assignment is restricted.""" + restricted: Boolean + + """The source or actor that applied the restriction.""" + restrictedBy: String + + """The name of the organization that caused the restriction.""" + restrictedOrgName: String +} + +"""An organization role definition.""" +type OrgRole { + """The role name.""" + name: String + + """The parent role this role extends, if any.""" + extends: String +} + +"""The available organization role IDs.""" +enum OrgRoleId { + """Organization administrator.""" + admin + + """Organization member.""" + member + + """Organization viewer.""" + viewer +} + +"""A paginated list of user organization roles.""" +type UserOrganizationRoleList implements ModelList { + """The total number of matching role assignments.""" + total: Int + + """The cursor for the next page of role assignments.""" + nextCursor: String + + """The role assignments returned in the current page.""" + nodes: [UserOrganizationRole] + + """A legacy alias for `nodes`.""" + edges: [UserOrganizationRole] +} + +"""VIP metadata collected for a pull request.""" +type VIPPRMeta { + """The review comments left on the pull request.""" + reviewComments: [GitHubPullRequestReviewComment] + + """The issue-style comments left on the pull request.""" + comments: [GitHubComment] + + """The formal reviews submitted on the pull request.""" + reviews: [GitHubReview] +} + +"""A 64-bit integer scalar.""" +scalar BigInt + +"""An ISO 8601 date-time scalar.""" +scalar Date + +"""MediaImportAllowedFileTypes scalar type""" +scalar MediaImportAllowedFileTypes + +"""The API audiences a schema field can target.""" +enum ApiAudience { + """Fields intended for people using the API directly.""" + HUMAN + + """Fields intended for AI agents using the API.""" + AGENT + + """Fields intended for internal-only use.""" + INTERNAL +} + +"""The top-level domains used to categorize public API fields.""" +enum ApiDomain { + """Application management fields.""" + APPS + + """Domain and certificate management fields.""" + DOMAINS + + """Organization management fields.""" + ORGANIZATIONS + + """User and identity management fields.""" + USERS + + """Integration and marketplace fields.""" + INTEGRATIONS + + """Security and access control fields.""" + SECURITY + + """Observability, metrics, and logs fields.""" + OBSERVABILITY +} + +"""A model with an integer identifier.""" +interface Model { + """The unique identifier for the model.""" + id: Int +} + +"""A paginated list of models.""" +interface ModelList { + """The models returned in the current page.""" + nodes: [Model] + + """The total number of matching models.""" + total: Int + + """The cursor for the next page of results.""" + nextCursor: String +} + +"""The root query type for the public API.""" +type Query { + """Retrieve a single application.""" + app( + """The application ID.""" + id: Int + ): App + + """Retrieve a paginated list of applications.""" + apps( + """The application IDs to include.""" + ids: [Int] + + """The exact application name to match.""" + name: String + + """The maximum number of applications to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """The free-text filter to match against applications.""" + matching: String + + """Filter applications by application type IDs.""" + appType: [Int] + + """Filter applications by multisite state.""" + isMultisite: Boolean + + """Filter applications by launch state.""" + launched: Boolean + + """The page number to fetch.""" + page: Int + ): AppList + + """Retrieve a single domain by ID or name.""" + domain( + """The domain ID.""" + id: Int + + """The domain name.""" + name: String + ): Domain + + """Retrieve Tollbit verification details for one or more domains.""" + tollbitDomainsVerification( + """The domain names to verify.""" + names: [String!]! + + """The domain names that should bypass cached verification data.""" + forceRefresh: [String!] + ): [TollbitDomainVerificationResult] + + """Retrieve a paginated list of domains.""" + domains( + """The wildcard patterns to filter by.""" + wildcards: [String] + + """The maximum number of domains to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + ): DomainList + + """Retrieve a single organization.""" + organization( + """The organization ID.""" + id: Int + ): Organization + + """Retrieve a paginated list of organizations.""" + organizations( + """The exact organization name to match.""" + name: String + + """The organization ID.""" + id: Int + + """The maximum number of organizations to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """The free-text filter to match against organizations.""" + matching: String + + """Page number to fetch.""" + page: Int + ): OrgList + + """Retrieve a paginated list of integration center entries.""" + integrationCenter( + """The maximum number of integration center entries to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """The free-text search term.""" + search: String + + """The integration slug to filter by.""" + slug: String + + """The integration category to filter by.""" + category: String + ): IntegrationCenterList + + """Retrieve the available integration center categories.""" + integrationCenterCategories: IntegrationCenterCategoryList + + """List integrations for an application, environment, or organization.""" + listIntegrations( + """The application ID to list integrations for.""" + applicationId: Int + + """The environment ID to list integrations for.""" + environmentId: Int + + """The organization ID to list integrations for.""" + organizationId: Int + ): IntegrationList + + """Retrieve repository details by name.""" + repo( + """The repository name in `owner/name` format.""" + name: String + ): Repo + + """Retrieve the currently authenticated user.""" + me: Me + + """Retrieve a single user by ID or GitHub username.""" + user( + """The user ID.""" + id: Int + + """The GitHub username.""" + githubUsername: String + ): User + + """Retrieve a paginated list of users.""" + users( + """Filter users by VIP status.""" + isVIP: Boolean + + """Filter users by organization membership.""" + organizationId: Int + + """Filter users by a free-text match.""" + matching: String + + """The maximum number of users to return.""" + first: Int + + """The pagination cursor to continue from.""" + after: String + + """The page number to fetch.""" + page: Int + + """Filter for external users.""" + externalUsers: Boolean + + """Filter for users with an organization admin role.""" + hasOrgAdminRole: Boolean + ): UserList + + """Retrieve a certificate for an organization.""" + certificate( + """The organization ID that owns the certificate.""" + clientId: Int + + """The certificate ID.""" + certificateId: Int + ): Certificate + + """Retrieve backup copy records for an environment.""" + dbBackupCopies( + """The environment ID to query.""" + environmentId: Int + + """The backup file names to filter by.""" + fileNames: [String] + ): DBBackupCopyList + + """List tokens for a specific purpose and set of environments.""" + listPurposeTokens( + """The token purpose to filter by.""" + purpose: String! + + """The environment IDs to include.""" + environmentIds: [Int]! + + """The user ID to filter by.""" + userId: Int + ): TokenList + + """Retrieve the current media import configuration.""" + mediaImportConfig: MediaImportConfig + + """ + Check if the site is ready for an Agentforce sync operation. + Verifies that configuration has propagated to the WordPress runtime. + Use this before enabling the sync button in the UI. + """ + agentforcePreflightCheck( + """The application, environment, and optional network site to validate.""" + input: AgentforcePreflightCheckInput! + ): AgentforcePreflightCheckPayload! + + """ + Get the current progress of an Agentforce sync operation. + Use this to poll for progress updates while a sync is running. + """ + agentforceSyncProgress( + """The application, environment, and optional network site to check.""" + input: AgentforceSyncProgressInput! + ): TriggerAgentforceSyncPayload! +} + +"""Agentforce integration for syncing WordPress content to Salesforce""" +type Agentforce { + """Get WordPress categories available for Agentforce sync""" + categories( + """ + Network site ID for multisite - specifies which subsite to list categories from + """ + networkSiteId: Int + + """Deprecated: use networkSiteId for multisite targeting""" + url: String @deprecated(reason: "Use networkSiteId instead") + ): [String!]! +} + +"""Input for Agentforce preflight check""" +input AgentforcePreflightCheckInput { + """The unique ID of the Application""" + applicationId: Int! + + """The unique ID of the Environment""" + environmentId: Int! + + """Network site ID for multisite - specifies which subsite to check""" + networkSiteId: Int + + """Deprecated: use networkSiteId for multisite sync targeting""" + url: String @deprecated(reason: "Use networkSiteId instead") +} + +"""Response payload for Agentforce preflight check""" +type AgentforcePreflightCheckPayload { + """Number of categories configured for sync""" + categoriesCount: Int! + + """Whether the vip_agentforce_should_ingest_post filter is registered""" + filterRegistered: Boolean! + + """Whether ingestion_api_object_name is configured""" + hasApiObject: Boolean! + + """Whether ingestion_api_source_name is configured""" + hasApiSource: Boolean! + + """Whether ingestion_api_token is configured""" + hasApiToken: Boolean! + + """Whether ingestion_api_instance_url is configured""" + hasApiUrl: Boolean! + + """Whether the site is ready for a sync operation""" + ready: Boolean! + + """Whether sync_all_posts is enabled in config""" + syncAllPosts: Boolean! +} + +"""Input for querying Agentforce sync progress""" +input AgentforceSyncProgressInput { + """The unique ID of the Application""" + applicationId: Int! + + """The unique ID of the Environment""" + environmentId: Int! + + """Network site ID for multisite - specifies which subsite to query""" + networkSiteId: Int + + """Deprecated: use networkSiteId for multisite sync targeting""" + url: String @deprecated(reason: "Use networkSiteId instead") +} + +""" +An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +""" +type AppEnvironment { + """Whether the environment is active.""" + active: Boolean + + """The currently active backup operation.""" + activeBackup: Backup + + """Agentforce integration for syncing WordPress content to Salesforce""" + agentforce: Agentforce + + """The allowlisted IP addresses for the environment.""" + allowedIPs: AppEnvironmentIPAllowList + + """Additional context for a specific anomaly.""" + anomalyContext( + """The anomaly ID.""" + anomalyId: Int + ): MetricAnomalyContext + + """The application ID that owns the environment.""" + appId: Int + + """The backup policy ID applied to the environment.""" + backupPolicyId: Int + + """The current V2 backup shipping configuration.""" + backupShippingConfigV2: AppEnvironmentBackupShippingV2 + + """The backups available for the environment.""" + backups( + """The pagination cursor to continue from.""" + after: String + + """The end date for filtering backups.""" + endDate: String + + """The maximum number of backups to return.""" + first: Int + + """The backup ID to retrieve.""" + id: Float + + """The start date for filtering backups.""" + startDate: String + ): BackupsList + + """The SQL dump tool used for backups.""" + backupsSqlDumpTool: String + + """The basic auth configuration for the environment.""" + basicAuth: AppEnvironmentBasicAuth + + """The currently configured branch for the environment.""" + branch: String + + """Available repository branches for the environment.""" + branches( + """The maximum number of branches to return.""" + limit: Int + ): AppEnvironmentBranchesList + + """The build configuration for the environment.""" + buildConfiguration: BuildConfiguration + + """The build history for the environment.""" + builds: BuildList + + """Get codebase related information""" + codebase: CodebaseInfo + + """ + WP-CLI commands executed on the environment. Returns a cursor-based list payload. + """ + commands( + """The pagination cursor to continue from.""" + after: String + + """The maximum number of commands to return.""" + first: Int + + """The sort order to apply.""" + order: String + + """Page number to fetch.""" + page: Int + + """The field to sort by.""" + sort: String + + """Filter commands by status.""" + status: String + ): WPCLICommandList + + """The recent commits relevant to the environment.""" + commits( + """The maximum number of commits to return.""" + first: Int + ): GitCommitList + + """When the environment was created.""" + createdAt: String + + """The current deployed commit SHA.""" + currentCommit: String + + """The custom error page configuration for the environment.""" + customErrorPageConfig: CustomErrorPageConfig + + """The datacenter serving the environment.""" + datacenter: String + + """Database backup copies available for the environment.""" + dbBackupCopies( + """The backup file names to filter by.""" + fileNames: [String] + ): DBBackupCopyList + + """Whether a database operation is currently in progress.""" + dbOperationInProgress: Boolean + + """The default domain assigned to the environment.""" + defaultDomain: String + + """The defensive mode configuration and state.""" + defensiveMode( + """The start date for the defensive mode reporting window.""" + fromDate: Date + + """The end date for the defensive mode reporting window.""" + toDate: Date + ): AppEnvironmentDefensiveMode + + """The deployment strategy configured for the environment.""" + deploymentStrategy: String + + """ + The deployments for the environment. This is the richer deployment view and supports cursor pagination plus a legacy `page` argument. + """ + deployments( + """The maximum number of deployments to return.""" + first: Int + + """The deployment ID to retrieve.""" + id: Int + + """The pagination cursor to continue from.""" + nextCursor: String + + """The page number to fetch.""" + page: Int + ): DeploymentList + + """ + The recent deploy records for the environment. This is a lightweight/legacy view. + """ + deploys( + """The maximum number of deploys to return.""" + first: Int + ): DeployList + + """The domains mapped to the environment.""" + domains( + """The pagination cursor to continue from.""" + after: String + + """Domain names to exclude from the results.""" + exclude: [String] + + """The maximum number of domains to return.""" + first: Int + + """Filter domains by verification status.""" + isVerified: Boolean + + """The free-text filter to match against domains.""" + matching: String + + """Page number to fetch.""" + page: Int + ): DomainList + + """The edge configuration for the environment.""" + edgeConfig: EdgeConfig + + """The WASM edge workers deployed to the environment.""" + edgeWorkers: [EdgeWorker!]! + + """The environment variables configured for the environment.""" + environmentVariables: EnvironmentVariablesList + + """ + The audit events recorded for the environment. Returns an AuditEventList with `total`, `nextCursor`, and `nodes`/`edges`. + """ + events( + """The pagination cursor to continue from.""" + after: String + + """Only include events after this timestamp.""" + afterTs: String + + """Only include events before this timestamp.""" + beforeTs: String + + """Whether to exclude anomaly events.""" + excludeAnomalyEvents: Boolean + + """Whether to exclude WP-CLI events.""" + excludeWPCLI: Boolean + + """The maximum number of events to return.""" + first: Int + + """Filter events by event types.""" + types: String + ): AuditEventList + + """Counts of audit events for the environment.""" + eventsCounts( + """The start of the reporting window.""" + from: String + + """The end of the reporting window.""" + to: String + + """The event types to include.""" + types: [String] + ): [AuditEventCount] + + """The development environment configuration for integrations.""" + getIntegrationsDevEnvConfig: IntegrationDevEnvConfig + + """Health metrics for the environment.""" + health( + """The end of the reporting window.""" + endDate: String + + """The start of the reporting window.""" + startDate: String + ): AppEnvironmentHealth + + """The HSTS settings for the environment.""" + hstsSettings: AppEnvironmentHSTSSettings + + """The icon for the environment.""" + icon( + """The requested icon size.""" + size: Int + ): AppEnvironmentIcon + + """The unique identifier for the environment.""" + id: Int + + """The current import status for the environment.""" + importStatus: AppEnvironmentImportStatus + + """ + An overview of insights and metrics for the environment over a date range. + """ + insightsOverview( + """The start date for the overview window.""" + fromDate: Date! + + """The end date for the overview window.""" + toDate: Date! + ): EnvironmentInsightsOverview + + """A single integration configured for the environment.""" + integration( + """The network site ID for a network-scoped integration.""" + networkSiteId: Int + + """The integration slug.""" + slug: String + ): Integration + + """ + The integrations configured for the environment. Returns an IntegrationList where `nodes` are `IntegrationListItem`. + """ + integrations: IntegrationList + + """The IP addresses assigned to the environment.""" + ips: AppEnvironmentIPs + + """Whether database partitioning is enabled.""" + isDBPartitioningEnabled: Boolean + + """Whether the environment is in a FedRAMP context.""" + isFedramp: Boolean + + """ + Whether the environment belongs to an organization with a signed BAA and must follow HIPAA requirements. + """ + isHipaa: Boolean + + """Whether the environment runs on Kubernetes.""" + isK8sResident: Boolean + + """Whether live backup copies are allowed for the environment.""" + isLiveBackupCopyAllowed: Boolean + + """Whether the environment is a multisite install.""" + isMultisite: Boolean + + """Whether the environment is running the latest deployed code.""" + isOnLatestCode: Boolean + + """Whether the multisite install uses subdirectories.""" + isSubdirectoryMultisite: Boolean + + """Jobs running on or related to the environment.""" + jobs( + """The job types to filter by as enum values.""" + jobTypes: [AppEnvironmentJobType!] + + """The job types to filter by as raw values.""" + types: [String!] + ): [JobInterface] + + """The most recent backup for the environment.""" + latestBackup: Backup + + """The most recent media export for the environment.""" + latestMediaExport: MediaExport + + """When launch mode ends for the environment.""" + launchModeEndAt: String + + """Whether the environment has been launched.""" + launched: Boolean + + """The live backup copies for the environment.""" + liveBackupCopies: [LiveBackupCopy] + + """The current V2 log shipping configuration.""" + logShippingConfig: AppEnvironmentLogShippingV2 + + """ + Application and platform logs for the environment. Use `type: app` or `type: batch`. Returns `pollingDelaySeconds` to guide incremental polling. + """ + logs( + """The pagination cursor to continue from.""" + after: String + + """The maximum number of log entries to return.""" + limit: Int + + """The log stream to retrieve.""" + type: AppEnvironmentLogType + ): AppEnvironmentLogsList + + """Media exports for the environment.""" + mediaExports( + """The pagination cursor to continue from.""" + nextCursor: String + ): MediaExportsList + + """The current media import status for the environment.""" + mediaImportStatus: AppEnvironmentMediaImportStatus + + """Metric anomalies detected for the environment.""" + metricAnomalies( + """The anomaly detection algorithm version.""" + algorithmVersion: String + + """Whether to exclude custom anomalies.""" + excludeCustomAnomalies: Boolean + + """The start date for the anomaly window.""" + fromDate: Date + + """The metric name to retrieve.""" + metricName: String + + """The end date for the anomaly window.""" + toDate: Date + ): MetricAnomaliesList + + """The metric thresholds configured for the environment.""" + metricThresholds( + """The metric name to filter by.""" + metricName: String + ): [MetricThreshold] + + """Aggregated metrics for the environment.""" + metrics( + """Whether to aggregate the metric series.""" + aggregate: Boolean + + """The start date for the metric window.""" + fromDate: Date + + """Whether to include baseline data.""" + includeBaseline: Boolean + + """The metric name to retrieve.""" + metricName: String + + """The end date for the metric window.""" + toDate: Date + ): AggregatedMetricMeasurements + + """The display name of the environment.""" + name: String + + """The New Relic configuration for the environment.""" + newRelic: AppEnvironmentNewRelic + + """The notification subscriptions configured for the environment.""" + notificationSubscriptions( + """Filter subscriptions by active status.""" + active: Boolean + + """The pagination cursor to continue from.""" + after: String + + """The maximum number of subscriptions to return.""" + first: Int + + """Filter subscriptions for a specific notification recipient.""" + notificationRecipientId: Int + ): NotificationSubscriptionList + + """Permission checks for the current user on this environment.""" + permissions( + """The permission keys to evaluate.""" + permissions: [String] + ): [PermissionResult] + + """The phpMyAdmin availability status for the environment.""" + phpMyAdminStatus: PHPMyAdminStatus + + """The primary domain for the environment.""" + primaryDomain: Domain + + """The progress of a primary domain switch.""" + primaryDomainSwitchProgress( + """The primary domain switch job ID.""" + primaryDomainSwitchId: Int + ): AppEnvironmentPrimaryDomainSwitchProgress + + """The repository name for the environment's codebase.""" + repo: String + + """The repository for the environment.""" + repository: GitRepository + + """Request statistics for the environment.""" + requestStats( + """The single date to query.""" + date: String + + """The number of days to include.""" + days: Int + + """The start date for the reporting window.""" + from: String + + """The number of months to include.""" + months: Int + + """The end date for the reporting window.""" + to: String + ): RequestStatsList + + """Database slow query logs for the environment.""" + slowlogs( + """The pagination cursor to continue from.""" + after: String + + """The maximum number of slow log entries to return.""" + limit: Int + ): AppEnvironmentSlowlogsList + + """The software details for the environment.""" + software: AppEnvironmentSoftwareDetails + + """The software settings for the environment.""" + softwareSettings: AppEnvironmentSoftwareSettings + + """A preview of the next environment sync.""" + syncPreview: AppEnvironmentSyncPreview + + """The current sync progress for the environment.""" + syncProgress( + """The sync job ID.""" + sync: Int + ): AppEnvironmentSyncProgress + + """The environment type, such as production or develop.""" + type: String + + """The unique label for the environment.""" + uniqueLabel: String + + """The current subsite domain update status.""" + updateSubsiteDomainStatus: AppEnvironmentUpdateSubsiteDomainStatus + + """Get WordPress Site Installation Details""" + wpInstallation: WPInstallation + + """Get WordPress Site Details""" + wpSites( + """The pagination cursor to continue from.""" + after: String + + """The maximum number of WordPress sites to return.""" + first: Int + ): WPSiteList + + """Get WordPress Site Details from SDS""" + wpSitesSDS( + """The pagination cursor to continue from.""" + after: String + + """The blog ID to filter by.""" + blogId: Int + + """The maximum number of WordPress sites to return.""" + first: Int + + """Filter sites by launch status.""" + launchStatus: WPSiteLaunchStatus + + """The free-text filter to match against sites.""" + matching: String + + """The sort order to apply.""" + order: String + + """Page number to fetch.""" + page: Int + + """The field to sort by.""" + sort: String + ): WPSiteList + + """The strategy used to execute WP-CLI commands.""" + wpcliStrategy: AppEnvironmentWPCliStrategy +} + +"""Mutation request input to abort a Media Import""" +input AppEnvironmentAbortMediaImportInput { + """The unique ID of the Application""" + applicationId: Int! + + """The uniqueID of the Environment""" + environmentId: Int! +} + +"""Response payload for aborting a Media Import""" +type AppEnvironmentAbortMediaImportPayload { + """The unique ID of the Application""" + applicationId: Int + + """The unique ID of the Environment""" + environmentId: Int + + """Media Import Abort Action Response""" + mediaImportStatusChange: AppEnvironmentMediaImportStatusChange +} + +"""Variables for the Activate Let's Encrypt Mutation""" +input AppEnvironmentActivateLetsEncryptOnDomainInput { + """The unique ID for the domain""" + domainId: Int + + """The ID of the environment that this domain belongs to""" + environmentId: Int + + """The unique ID for the domain""" + id: Int + + """Provisions the www-alt domain""" + includeWWW: Boolean = true + + """Overrides the existing certificate (if any) on the domain""" + overrideExisting: Boolean +} + +"""Response from the Activate Let's Encrypt Mutation""" +type AppEnvironmentActivateLetsEncryptOnDomainPayload { + """The domain that Let's Encrypt was activated on""" + domain: Domain +} + +"""Variables for the Add Domain mutation""" +input AppEnvironmentAddDomainInput { + """The domain name (i.e. something like example.com or sub.example.com)""" + domain: NewDomain + + """The ID of the environment that this domain belongs to""" + environmentId: Int + + """Flag to set verification code""" + generateVerificationCode: Boolean + + """The App ID""" + id: Int +} + +"""The result of adding a domain to an environment.""" +type AppEnvironmentAddDomainPayload { + """The added domain.""" + domain: Domain +} + +"""Variables for the AddRequestStats mutation""" +input AppEnvironmentAddRequestStatsInput { + """The application ID""" + applicationId: Int! + + """Date for which we want to sync - if we want to sync only for one day""" + date: String + + """The environment ID where we want to run the command""" + environmentId: Int! + + """Date range for which we want to sync - if we want to sync for a range""" + fromDate: String + + """The end date for the sync range.""" + toDate: String +} + +"""Response payload for Request Stats""" +type AppEnvironmentAddRequestStatsPayload { + """The unique ID of the Application""" + applicationId: Int! + + """The unique ID of the environment""" + environmentId: Int! +} + +"""A lightweight backup summary for an environment.""" +type AppEnvironmentBackup { + """When the backup was created.""" + createdAt: String + + """The backup ID.""" + id: Int + + """The backup size in bytes.""" + size: Int +} + +"""Input for deleting backup shipping configuration.""" +input AppEnvironmentBackupShippingDeleteInput { + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""The result of a backup shipping operation.""" +type AppEnvironmentBackupShippingOperationResultPayload { + """A human-readable result message.""" + message: String! + + """Whether the operation succeeded.""" + success: Boolean! +} + +"""Input for enabling or disabling backup shipping.""" +input AppEnvironmentBackupShippingUpdateStatusInput { + """Whether backup shipping should be enabled.""" + enabled: Boolean! + + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""The current backup shipping configuration for an environment.""" +type AppEnvironmentBackupShippingV2 { + """The daily hour used for daily schedules.""" + dailyHour: Int + + """Whether backup shipping is enabled.""" + enabled: Boolean! + + """The Azure configuration, when using Azure Blob Storage.""" + object_storage_config_azure: CloudShippingObjectStorageConfigAzure + + """The GCP configuration, when using Google Cloud Storage.""" + object_storage_config_gcp: CloudShippingObjectStorageConfigGCP + + """The S3 configuration, when using Amazon S3.""" + object_storage_config_s3: CloudShippingObjectStorageConfigS3 + + """The destination path prefix.""" + path: String + + """The object storage provider receiving the backups.""" + provider: CloudShippingObjectStorageProviders! + + """The backup shipping schedule.""" + schedule: BackupShippingSchedule! +} + +""" +Input for updating V2 backup shipping configuration. `provider` is required and callers should provide the matching provider-specific `object_storage_config_*` block. +""" +input AppEnvironmentBackupShippingV2Input { + """The daily hour used for daily schedules.""" + dailyHour: Int + + """Whether backup shipping is enabled.""" + enabled: Boolean! + + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! + + """The Azure configuration, used when `provider` is `azure_blob_storage`.""" + object_storage_config_azure: CloudShippingObjectStorageConfigAzureInput + + """The GCP configuration, used when `provider` is `gcp_cloud_storage`.""" + object_storage_config_gcp: CloudShippingObjectStorageConfigGCPInput + + """The S3 configuration, used when `provider` is `aws_s3`.""" + object_storage_config_s3: CloudShippingObjectStorageConfigS3Input + + """The destination path prefix.""" + path: String + + """The object storage provider.""" + provider: CloudShippingObjectStorageProviders! + + """The backup shipping schedule.""" + schedule: BackupShippingSchedule +} + +"""The basic auth users configured for an environment.""" +type AppEnvironmentBasicAuth { + """The total number of basic auth users.""" + total: Int + + """The basic auth usernames.""" + users: [String] +} + +"""Input for deleting basic auth users.""" +input AppEnvironmentBasicAuthDeleteInput { + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int + + """The usernames to delete.""" + username: [String] +} + +"""Input for creating or updating basic auth users.""" +input AppEnvironmentBasicAuthInput { + """The basic auth users to store.""" + basicAuth: [AppEnvironmentBasicAuthUserInput] + + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int +} + +"""The result of a basic auth operation.""" +type AppEnvironmentBasicAuthPayload { + """The application that owns the environment.""" + app: App + + """The username affected by the operation.""" + user: String +} + +"""A basic auth user definition.""" +input AppEnvironmentBasicAuthUserInput { + """The basic auth password.""" + password: String + + """The basic auth username.""" + username: String +} + +"""A single repository branch.""" +type AppEnvironmentBranch { + """The branch name.""" + name: String +} + +"""A paginated list of repository branches.""" +type AppEnvironmentBranchesList { + """The cursor for the next page of branches.""" + nextCursor: String + + """The branches returned in the current page.""" + nodes: [AppEnvironmentBranch] + + """The suggested polling delay before fetching branches again.""" + pollingDelaySeconds: Int! + + """The total number of branches.""" + total: BigInt +} + +"""Input for completing an Elasticsearch upgrade.""" +input AppEnvironmentCompleteElasticsearchUpgradeInput { + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""Input for creating a child environment from a production environment""" +input AppEnvironmentCreateChildEnvironmentInput { + """The unique ID of the parent environment""" + appId: Int! + + """The branch to use for the child environment""" + branch: String + + """The name for the new child environment""" + environmentName: String! + + """The Node.js version for the child environment""" + nodejsVersion: String + + """The PHP version for the child environment""" + phpVersion: String +} + +"""Response from creating a child environment""" +type AppEnvironmentCreateChildEnvironmentPayload { + """The unique ID of the newly created child environment""" + environmentId: Int! + + """The name of the newly created child environment""" + environmentName: String! + + """Success message""" + message: String! + + """Whether the operation was successful""" + success: Boolean! +} + +"""Input for deactivating a domain on an environment.""" +input AppEnvironmentDeactivateDomainInput { + """The domain ID to deactivate.""" + domainId: Int + + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int +} + +"""The result of deactivating a domain on an environment.""" +type AppEnvironmentDeactivateDomainPayload { + """The deactivated domain.""" + domain: Domain +} + +"""The defensive mode state for an environment.""" +type AppEnvironmentDefensiveMode { + """The current defensive mode configuration.""" + config: AppEnvironmentDefensiveModeConfig! +} + +"""Stored and effective defensive mode configuration.""" +type AppEnvironmentDefensiveModeConfig { + """The effective configuration after defaults are applied.""" + effective: AppEnvironmentDefensiveModeConfigObject! + + """The configuration explicitly stored for the environment.""" + stored: AppEnvironmentDefensiveModeConfigObject +} + +"""Input for updating defensive mode configuration.""" +input AppEnvironmentDefensiveModeConfigInput { + """The challenge type to apply.""" + challengeType: Int! + + """The absolute connection threshold that triggers defensive mode.""" + connectionThresholdAbsolute: Int + + """The connection threshold percentage that triggers defensive mode.""" + connectionThresholdPercentage: Int + + """Whether defensive mode should be enabled.""" + enabled: Boolean! + + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""A defensive mode configuration object.""" +type AppEnvironmentDefensiveModeConfigObject { + """The challenge type applied while defensive mode is enabled.""" + challengeType: Int + + """The absolute connection threshold that triggers defensive mode.""" + connectionThresholdAbsolute: Int + + """The connection threshold percentage that triggers defensive mode.""" + connectionThresholdPercentage: Int + + """When defensive mode should automatically disable, as a Unix timestamp.""" + disableAtEpoch: Int + + """Whether defensive mode is enabled.""" + enabled: Boolean + + """ + How long to keep defensive mode enabled after traffic drops below threshold. + """ + keepEnabledUnderThresholdForSeconds: Int + + """The maximum request rate allowed.""" + maxRequestRate: Int + + """The priority bypass value.""" + priorityBypass: Int +} + +"""The result of a defensive mode operation.""" +type AppEnvironmentDefensiveModeOperationResultPayload { + """A human-readable result message.""" + message: String! + + """Whether the operation succeeded.""" + success: Boolean! +} + +"""Input for enabling or disabling defensive mode.""" +input AppEnvironmentDefensiveModeUpdateStatusInput { + """Whether defensive mode should be enabled.""" + enabled: Boolean! + + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""The result of an Elasticsearch upgrade operation.""" +type AppEnvironmentElasticsearchUpgradePayload { + """A human-readable result message.""" + message: String! + + """Whether the operation succeeded.""" + success: Boolean! +} + +"""Input for enabling launch mode on an environment.""" +input AppEnvironmentEnableLaunchModeInput { + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int + + """When launch mode should end.""" + launchModeEndAt: String +} + +"""The result of enabling launch mode.""" +type AppEnvironmentEnableLaunchModePayload { + """The application that owns the environment.""" + app: App + + """The updated environment.""" + environment: AppEnvironment +} + +"""Input for enqueueing an Elasticsearch upgrade.""" +input AppEnvironmentEnqueueElasticsearchUpgradeInput { + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! + + """The target Elasticsearch version.""" + version: String +} + +"""Input for generating a database backup copy download URL.""" +input AppEnvironmentGenerateDBBackupCopyUrlInput { + """The backup ID to generate a URL for.""" + backupId: Float + + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int +} + +"""The result of generating a database backup copy download URL.""" +type AppEnvironmentGenerateDBBackupCopyUrlPayload { + """The application that owns the environment.""" + app: App + + """Whether the operation succeeded.""" + success: Boolean + + """The generated download URL.""" + url: String +} + +"""Input for generating a signed URL for a media export.""" +input AppEnvironmentGenerateMediaExportSignedUrlInput { + """The application ID that owns the environment.""" + appId: Int + + """The archive file index to fetch, if applicable.""" + archiveFileIndex: Int + + """The environment ID the export belongs to.""" + environmentId: Int + + """The media export ID to generate a URL for.""" + mediaExportId: Float + + """The export target to generate a URL for.""" + target: AppEnvironmentGenerateMediaExportSignedUrlTarget +} + +"""The result of generating a signed URL for a media export.""" +type AppEnvironmentGenerateMediaExportSignedUrlPayload { + """Whether the signed URL was generated successfully.""" + success: Boolean + + """The generated signed URL.""" + url: String +} + +"""The available signed URL targets for a media export.""" +enum AppEnvironmentGenerateMediaExportSignedUrlTarget { + """The export report file.""" + report + + """The exported media archive.""" + media +} + +"""A generic software version entry for an application environment.""" +type AppEnvironmentGenericSoftware implements AppEnvironmentSoftware { + """The version currently installed.""" + version: String! +} + +"""Details about the environment's HSTS settings""" +type AppEnvironmentHSTSSettings { + """Whether HSTS is enabled for an App Environment""" + enabled: Boolean + + """Whether the header includes the includesSubdomains directive""" + includeSubdomains: Boolean + + """The value of the max-age directive""" + maxAge: Int + + """Whether the header includes the preload directive""" + preload: Boolean + + """Whether the App Environment enforces HTTPS everywhere""" + sslEverywhere: Boolean +} + +"""Variables for the UpdateHSTSSettings mutation""" +input AppEnvironmentHSTSSettingsInput { + """The unique ID of the Environment""" + environmentId: Int! + + """The unique ID of the Application""" + id: Int! + + """Whether the header should include the includesSubdomains directive""" + includeSubdomains: Boolean + + """The value of the max-age directive""" + maxAge: Int + + """Whether the header should include the preload directive""" + preload: Boolean +} + +"""Response payload for HSTS Settings updates""" +type AppEnvironmentHSTSSettingsPayload { + """The Application that was updated""" + app: App + + """The response message from GOOP""" + message: String + + """Whether the update was successful""" + success: Boolean +} + +"""Health metrics for an environment.""" +type AppEnvironmentHealth { + """Cache hit totals over time.""" + cacheHit: AppEnvironmentHealthCacheList + + """Cache miss totals over time.""" + cacheMiss: AppEnvironmentHealthCacheList + + """HTTP response code totals over time.""" + responseCodes: AppEnvironmentHealthList +} + +"""Aggregated cache metrics.""" +type AppEnvironmentHealthCacheList { + """The cache metrics grouped by time window.""" + nodes: [AppEnvironmentHealthCacheNodes] + + """The total number of cache events recorded.""" + total: BigInt +} + +"""Cache metrics for a single time window.""" +type AppEnvironmentHealthCacheNodes { + """The start of the time window.""" + from: String + + """The end of the time window.""" + to: String + + """The total number of cache events in the time window.""" + total: BigInt +} + +"""Aggregated HTTP response code metrics.""" +type AppEnvironmentHealthList { + """The distinct HTTP response codes returned.""" + codes: [String] + + """The response code metrics grouped by time window.""" + nodes: [AppEnvironmentHealthNodes] + + """The total number of responses recorded.""" + total: BigInt +} + +"""HTTP response code metrics for a single time window.""" +type AppEnvironmentHealthNodes { + """The count of HTTP 200 responses.""" + _200: BigInt + + """The count of HTTP 201 responses.""" + _201: BigInt + + """The count of HTTP 206 responses.""" + _206: BigInt + + """The count of HTTP 301 responses.""" + _301: BigInt + + """The count of HTTP 302 responses.""" + _302: BigInt + + """The count of HTTP 304 responses.""" + _304: BigInt + + """The count of HTTP 400 responses.""" + _400: BigInt + + """The count of HTTP 401 responses.""" + _401: BigInt + + """The count of HTTP 403 responses.""" + _403: BigInt + + """The count of HTTP 404 responses.""" + _404: BigInt + + """The count of HTTP 405 responses.""" + _405: BigInt + + """The count of HTTP 408 responses.""" + _408: BigInt + + """The count of HTTP 412 responses.""" + _412: BigInt + + """The count of HTTP 416 responses.""" + _416: BigInt + + """The count of HTTP 429 responses.""" + _429: BigInt + + """The count of HTTP 499 responses.""" + _499: BigInt + + """The count of HTTP 500 responses.""" + _500: BigInt + + """The count of HTTP 502 responses.""" + _502: BigInt + + """The count of HTTP 503 responses.""" + _503: BigInt + + """The count of HTTP 504 responses.""" + _504: BigInt + + """The start of the time window.""" + from: String + + """The end of the time window.""" + to: String + + """The total number of responses in the time window.""" + total: BigInt +} + +"""The IP allow list for an environment.""" +type AppEnvironmentIPAllowList { + """The allowlisted IPs.""" + ips: [String] + + """The total number of allowlisted IPs.""" + total: Int +} + +"""The IP addresses assigned to an environment.""" +type AppEnvironmentIPs { + """The IPv4 addresses.""" + ipv4: [String] + + """The IPv6 addresses.""" + ipv6: [String] +} + +"""An icon for an environment.""" +type AppEnvironmentIcon { + """The icon height in pixels.""" + height: Int + + """The icon MIME type.""" + type: String + + """The icon URL.""" + url: String + + """The icon width in pixels.""" + width: Int +} + +"""Input for starting an environment import.""" +input AppEnvironmentImportInput { + """The backup basename to import.""" + basename: String + + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int + + """The expected MD5 checksum.""" + md5: String + + """The search-and-replace rules to apply.""" + searchReplace: [AppEnvironmentImportSearchReplace] + + """Whether to skip creating a backup before import.""" + skipBackup: Boolean + + """Whether to skip maintenance mode during import.""" + skipMaintenanceMode: Boolean + + """The source URL to import from.""" + url: String + + """The request headers to include when fetching the source URL.""" + urlHeaders: [RequestHeader!] +} + +"""The result of starting an environment import.""" +type AppEnvironmentImportPayload { + """The application that owns the environment.""" + app: App + + """A human-readable result message.""" + message: String + + """Whether the operation succeeded.""" + success: Boolean +} + +"""A search-and-replace rule applied during import.""" +input AppEnvironmentImportSearchReplace { + """The source string to replace.""" + from: String + + """The replacement string.""" + to: String +} + +"""The current status of an environment import.""" +type AppEnvironmentImportStatus { + """Whether any database operation is currently in progress.""" + dbOperationInProgress: Boolean + + """Whether an import is currently in progress.""" + importInProgress: Boolean + + """Detailed progress information for the import.""" + progress: AppEnvironmentStatusProgress +} + +"""The job types supported for environments.""" +enum AppEnvironmentJobType { + """Switch the primary domain.""" + set_primary_domain + + """Import a SQL database.""" + sql_import + + """Create a database backup copy.""" + db_backup_copy + + """Update a multisite subsite domain.""" + update_subsite_domain + + """Upgrade the PHP version.""" + upgrade_php + + """Upgrade the WordPress version.""" + upgrade_wordpress + + """Upgrade the MU plugins version.""" + upgrade_muplugins + + """Upgrade the Node.js version.""" + upgrade_nodejs + + """Run a database backup.""" + db_backup +} + +"""Input for marking an application as launched.""" +input AppEnvironmentLaunchedInput { + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int +} + +"""The result of marking an application as launched.""" +type AppEnvironmentLaunchedPayload { + """The application that owns the environment.""" + app: App + + """The updated environment.""" + environment: AppEnvironment +} + +"""Input for generating a live backup copy download URL.""" +input AppEnvironmentLiveBackupCopyDownloadURLInput { + """The live backup copy ID.""" + copyId: String! + + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""The result of generating a live backup copy download URL.""" +type AppEnvironmentLiveBackupCopyDownloadURLPayload { + """Whether the live backup copy is still processing.""" + processing: Boolean! + + """The size of the downloadable copy in bytes.""" + size: BigInt + + """Whether the operation succeeded.""" + success: Boolean! + + """The generated download URL.""" + url: String +} + +"""A single environment log entry.""" +type AppEnvironmentLog { + """The log message.""" + message: String + + """When the log entry was recorded.""" + timestamp: String +} + +"""Input for deleting log shipping configuration.""" +input AppEnvironmentLogShippingDeleteInput { + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""The result of a log shipping operation.""" +type AppEnvironmentLogShippingOperationResultPayload { + """A human-readable result message.""" + message: String! + + """Whether the operation succeeded.""" + success: Boolean! +} + +"""Input for enabling or disabling log shipping.""" +input AppEnvironmentLogShippingUpdateStatusInput { + """Whether log shipping should be enabled.""" + enabled: Boolean! + + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""The current log shipping configuration for an environment.""" +type AppEnvironmentLogShippingV2 { + """Whether log shipping is enabled.""" + enabled: Boolean! + + """When shipping last failed.""" + last_failed_shipping_time: String + + """The most recent shipping error message.""" + last_shipping_error_message: String + + """The Azure configuration, when using Azure Blob Storage.""" + object_storage_config_azure: CloudShippingObjectStorageConfigAzure + + """The GCP configuration, when using Google Cloud Storage.""" + object_storage_config_gcp: CloudShippingObjectStorageConfigGCP + + """The S3 configuration, when using Amazon S3.""" + object_storage_config_s3: CloudShippingObjectStorageConfigS3 + + """The destination path prefix.""" + path: String + + """The object storage provider receiving the logs.""" + provider: CloudShippingObjectStorageProviders! + + """The log streams being shipped.""" + type: [CloudShippingLogsType!]! +} + +""" +Input for updating V2 log shipping configuration. `provider` is required and callers should provide the matching provider-specific `object_storage_config_*` block. +""" +input AppEnvironmentLogShippingV2Input { + """Whether log shipping is enabled.""" + enabled: Boolean! + + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! + + """The Azure configuration, used when `provider` is `azure_blob_storage`.""" + object_storage_config_azure: CloudShippingObjectStorageConfigAzureInput + + """The GCP configuration, used when `provider` is `gcp_cloud_storage`.""" + object_storage_config_gcp: CloudShippingObjectStorageConfigGCPInput + + """The S3 configuration, used when `provider` is `aws_s3`.""" + object_storage_config_s3: CloudShippingObjectStorageConfigS3Input + + """The destination path prefix.""" + path: String + + """The object storage provider.""" + provider: CloudShippingObjectStorageProviders! + + """The log streams to ship.""" + type: [CloudShippingLogsType!]! +} + +"""The available environment log streams.""" +enum AppEnvironmentLogType { + """Application logs (`type: app`).""" + app + + """Batch job logs (`type: batch`).""" + batch +} + +"""A paginated list of environment log entries.""" +type AppEnvironmentLogsList { + """The cursor for the next page of log entries.""" + nextCursor: String + + """The log entries returned in the current page.""" + nodes: [AppEnvironmentLog] + + """The suggested polling delay before fetching logs again.""" + pollingDelaySeconds: Int! + + """The total number of log entries.""" + total: BigInt +} + +"""Response payload for starting and fetching a Media Import""" +type AppEnvironmentMediaImportPayload { + """The unique ID of the Application""" + applicationId: Int + + """The unique ID of the Environment""" + environmentId: Int + + """Media Import Status""" + mediaImportStatus: AppEnvironmentMediaImportStatus! +} + +"""Current status of a Media Import""" +type AppEnvironmentMediaImportStatus { + """Media Import failure details""" + failureDetails: AppEnvironmentMediaImportStatusFailureDetails + + """URL to download the media import error log""" + failureDetailsUrl: String + + """Total number of media files that were imported""" + filesProcessed: Int + + """Total number of media files that are to be import""" + filesTotal: Int + + """Unique Identifier for a Media Import""" + importId: Int + + """Alias of environmentId""" + siteId: Int + + """The actual status of the Media Import""" + status: String +} + +""" +Response payload for executing a status change action on a Media Import +""" +type AppEnvironmentMediaImportStatusChange { + """Unique Identifier for a Media Import""" + importId: Int + + """Alias of environmentId""" + siteId: Int + + """The status of Media Import prior to status change action""" + statusFrom: String + + """The status of Media Import after the status change action""" + statusTo: String +} + +"""Media Import Failure details""" +type AppEnvironmentMediaImportStatusFailureDetails { + """List of errors per file""" + fileErrors: [AppEnvironmentMediaImportStatusFailureDetailsFileErrors] + + """URL to download the media import error log""" + fileErrorsUrl: String + + """List of global errors per import""" + globalErrors: [String] + + """Status of the Media Import prior to failing""" + previousStatus: String +} + +"""Media Import File Errors""" +type AppEnvironmentMediaImportStatusFailureDetailsFileErrors { + """List of Errors per file""" + errors: [String] + + """File Name""" + fileName: String +} + +"""New Relic configuration and status for an environment.""" +type AppEnvironmentNewRelic { + """Whether the current user can manage New Relic users.""" + canManageUsers: Boolean + + """The New Relic dashboard URL.""" + dashboardUrl: String + + """When New Relic is scheduled for deactivation.""" + deactivationTimestamp: String + + """Whether New Relic is enabled.""" + enabled: Boolean + + """Whether New Relic setup has been completed.""" + isSetupComplete: Boolean + + """The sampling percentage configured for New Relic.""" + samplingPercentage: BigInt + + """The New Relic users associated with the environment.""" + users: AppEnvironmentNewRelicUsersList +} + +"""A New Relic user associated with an environment.""" +type AppEnvironmentNewRelicUser { + """The email address of the New Relic user.""" + email: String + + """The New Relic user ID.""" + id: BigInt + + """The display name of the New Relic user.""" + name: String +} + +"""A paginated list of New Relic users.""" +type AppEnvironmentNewRelicUsersList { + """The cursor for the next page of New Relic users.""" + nextCursor: String + + """The New Relic users returned in the current page.""" + nodes: [AppEnvironmentNewRelicUser] + + """The total number of New Relic users.""" + total: BigInt +} + +"""Input for switching an environment's primary domain.""" +input AppEnvironmentPrimaryDomainSwitchInput { + """The domain ID to promote to primary.""" + domainId: Int + + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int +} + +"""The result of starting a primary domain switch.""" +type AppEnvironmentPrimaryDomainSwitchPayload { + """The application that owns the environment.""" + app: App + + """The target domain.""" + domain: Domain + + """The updated environment.""" + environment: AppEnvironment + + """The primary domain switch job ID.""" + primaryDomainSwitchId: Int +} + +"""Progress details for a primary domain switch.""" +type AppEnvironmentPrimaryDomainSwitchProgress { + """The destination domain name.""" + destinationDomain: String + + """The primary domain switch job ID.""" + primaryDomainSwitchId: Int + + """The source domain name.""" + sourceDomain: String + + """The overall status of the switch.""" + status: String + + """The individual steps in the switch.""" + steps: [AppEnvironmentPrimaryDomainSwitchProgressStep] +} + +"""A single step in a primary domain switch.""" +type AppEnvironmentPrimaryDomainSwitchProgressStep { + """The display name of the step.""" + name: String + + """The step status.""" + status: String + + """The step identifier.""" + step: String +} + +"""Input for retiring an environment.""" +input AppEnvironmentRetireInput { + """The unique ID of the Environment""" + environmentId: Int! + + """The unique ID of the Application""" + id: Int! +} + +"""The result of retiring an environment.""" +type AppEnvironmentRetirePayload { + """The response message from GOOP""" + message: String + + """Whether the retirement was successful""" + success: Boolean +} + +"""A single slow query log entry.""" +type AppEnvironmentSlowlog { + """The SQL query text.""" + query: String + + """How long the query took to execute.""" + queryTime: String + + """The request URI associated with the slow query.""" + requestUri: String + + """The number of rows examined by the query.""" + rowsExamined: String + + """The number of rows returned by the query.""" + rowsSent: String + + """When the slow query was recorded.""" + timestamp: String +} + +"""A paginated list of slow log entries.""" +type AppEnvironmentSlowlogsList { + """The cursor for the next page of slow log entries.""" + nextCursor: String + + """The slow log entries returned in the current page.""" + nodes: [AppEnvironmentSlowlog] + + """The suggested polling delay before fetching slow logs again.""" + pollingDelaySeconds: Int! + + """The total number of slow log entries.""" + total: BigInt +} + +"""A software component installed on an application environment.""" +interface AppEnvironmentSoftware { + """The version currently installed.""" + version: String! +} + +"""Installed software versions for an application environment.""" +type AppEnvironmentSoftwareDetails { + """The installed Node.js version.""" + nodejs: AppEnvironmentGenericSoftware + + """The installed PHP version.""" + php: AppEnvironmentGenericSoftware + + """The installed WordPress version.""" + wordpress: AppEnvironmentGenericSoftware +} + +"""Available software settings for an application environment.""" +type AppEnvironmentSoftwareSettings { + """The mu-plugins software settings.""" + muplugins: AppEnvironmentSoftwareSettingsSoftware + + """The Node.js software settings.""" + nodejs: AppEnvironmentSoftwareSettingsSoftware + + """The PHP software settings.""" + php: AppEnvironmentSoftwareSettingsSoftware + + """The WordPress software settings.""" + wordpress: AppEnvironmentSoftwareSettingsSoftware +} + +"""Variables for the UpdateSoftwareSettings mutation""" +input AppEnvironmentSoftwareSettingsInput { + """The unique ID of the Application""" + appId: Int! + + """The unique ID of the Environment""" + environmentId: Int! + + """The name of the software being updated""" + softwareName: String! + + """The version the software is being updated to""" + softwareVersion: String! +} + +"""Software settings and available versions for one software package.""" +type AppEnvironmentSoftwareSettingsSoftware { + """The currently selected version.""" + current: AppEnvironmentSoftwareSettingsVersion! + + """The display name of the software.""" + name: String! + + """The available version options.""" + options: [AppEnvironmentSoftwareSettingsVersion!]! + + """Whether the software version is pinned.""" + pinned: Boolean! + + """The internal slug of the software.""" + slug: String! +} + +"""A software version option available for an environment.""" +type AppEnvironmentSoftwareSettingsVersion { + """Whether this version is compatible with the environment.""" + compatible: Boolean! + + """Whether this is the default version.""" + default: Boolean! + + """Whether this version is deprecated.""" + deprecated: Boolean! + + """The latest available release for this software.""" + latestRelease: String! + + """Whether this version is private.""" + private: Boolean! + + """Whether this version is unstable.""" + unstable: Boolean! + + """The version identifier.""" + version: String! +} + +"""Input for starting a database backup copy.""" +input AppEnvironmentStartDBBackupCopyInput { + """The backup ID to copy.""" + backupId: Float + + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int + + """The subsite ID to target, when applicable.""" + subsiteId: Int + + """The tables to include in the copy.""" + tables: [String] +} + +"""The result of starting a database backup copy.""" +type AppEnvironmentStartDBBackupCopyPayload { + """The application that owns the environment.""" + app: App + + """A human-readable result message.""" + message: String + + """Whether the operation succeeded.""" + success: Boolean +} + +"""The result of starting a live backup copy.""" +type AppEnvironmentStartLiveBackupCopyPayload { + """The live backup copy ID.""" + copyId: String + + """A human-readable result message.""" + message: String + + """Whether the operation succeeded.""" + success: Boolean +} + +"""Mutation request input to start a Media Import""" +input AppEnvironmentStartMediaImportInput { + """API version to be used for the media import""" + apiVersion: String + + """The unique ID of the Application""" + applicationId: Int! + + """ + Publicly accessible URL that contains an archive of the media files to be imported + """ + archiveUrl: String! + + """The uniqueID of the Environment""" + environmentId: Int! + + """Whether to import intermediate images or not""" + importIntermediateImages: Boolean + + """Whether to overwrite existing files or not""" + overwriteExistingFiles: Boolean +} + +"""Progress details for an environment operation.""" +type AppEnvironmentStatusProgress { + """When the operation finished, as a Unix timestamp.""" + finished_at: Int + + """When the operation started, as a Unix timestamp.""" + started_at: Int + + """The steps completed by the operation.""" + steps: [AppEnvironmentStatusProgressStep] +} + +"""A single step in an environment progress flow.""" +type AppEnvironmentStatusProgressStep { + """When the step finished, as a Unix timestamp.""" + finished_at: Int + + """The display name of the step.""" + name: String + + """The output lines produced by the step.""" + output: [String] + + """The result of the step.""" + result: String + + """When the step started, as a Unix timestamp.""" + started_at: Int +} + +"""The sync configuration preview for an environment.""" +type AppEnvironmentSyncConfig { + """The config files involved in the sync.""" + files: [AppEnvironmentSyncConfigFile] + + """The generated `settings.yml` contents.""" + settingsYml: String +} + +"""A config file included in an environment sync preview.""" +type AppEnvironmentSyncConfigFile { + """The API URL for the file.""" + apiUrl: String + + """The branch containing the file.""" + branch: String + + """The file contents.""" + contents: String + + """The file name.""" + filename: String + + """The HTML URL for the file.""" + htmlUrl: String + + """The repository containing the file.""" + repo: String +} + +"""A sync validation error.""" +type AppEnvironmentSyncError { + """The machine-readable error code.""" + code: String + + """The error message.""" + message: String +} + +"""Input for triggering an environment sync.""" +input AppEnvironmentSyncInput { + """The copy configuration payload.""" + config: JSON + + """The environment ID to sync.""" + environmentId: Int! + + """The source environment ID to sync from.""" + fromEnvironmentId: Int + + """The application ID.""" + id: Int! +} + +"""The result of triggering an environment sync.""" +type AppEnvironmentSyncPayload { + """The application that owns the environment.""" + app: App + + """The environment being synced.""" + environment: AppEnvironment +} + +"""A preview of whether an environment can be synced.""" +type AppEnvironmentSyncPreview { + """The backup that will be used for sync.""" + backup: AppEnvironmentBackup + + """Whether the environment can be synced.""" + canSync: Boolean + + """The configuration preview for the sync.""" + config: AppEnvironmentSyncConfig + + """The validation errors preventing sync.""" + errors: [AppEnvironmentSyncError] + + """The source environment reference.""" + from: AppEnvironment + + """The URL used to create sync file configuration in GitHub.""" + githubCreateSyncFileConfigURL: String + + """The replacements that will be applied during sync.""" + replacements: [AppEnvironmentSyncReplacement] + + """The source environment to sync from.""" + sourceEnvironment: AppEnvironment + + """The destination environment reference.""" + to: AppEnvironment +} + +"""Progress details for an environment sync.""" +type AppEnvironmentSyncProgress { + """When the sync finished, as a Unix timestamp.""" + finished_at: Int + + """When the sync started, as a Unix timestamp.""" + started_at: Int + + """The overall sync status.""" + status: String + + """The individual sync steps.""" + steps: [AppEnvironmentSyncStep] + + """The sync job ID.""" + sync: Int +} + +"""A string replacement that will be applied during sync.""" +type AppEnvironmentSyncReplacement { + """The source value.""" + from: String + + """The replacement value.""" + to: String +} + +"""A single step in an environment sync.""" +type AppEnvironmentSyncStep { + """The display name of the step.""" + name: String + + """The step status.""" + status: String + + """The step identifier.""" + step: String +} + +"""Input for triggering a database backup.""" +input AppEnvironmentTriggerDBBackupInput { + """Whether to perform a dry run.""" + dryRun: Boolean + + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""The result of triggering a database backup.""" +type AppEnvironmentTriggerDBBackupPayload { + """Whether the operation succeeded.""" + success: Boolean +} + +"""Variables for the Run WP-CLI Command mutation""" +input AppEnvironmentTriggerWPCLICommandInput { + """The command we want to run. Note: should not include 'wp'""" + command: String + + """The environment ID where we want to run the command""" + environmentId: Int + + """The application ID""" + id: Int +} + +"""Response from the Run WP-CLI Command mutation""" +type AppEnvironmentTriggerWPCLICommandPayload { + """The command that was executed""" + command: WPCLICommand + + """The token for authenticating the socket connection""" + inputToken: String + + """The SSH credentials for connecting to the command session.""" + sshAuthentication: WPCliSSHAuthentication +} + +"""Input for updating a multisite subsite domain.""" +input AppEnvironmentUpdateSubsiteDomainInput { + """The domain ID to assign.""" + domainId: Int + + """The environment ID.""" + environmentId: Int + + """The application ID.""" + id: Int + + """The subsite ID to update.""" + subsiteId: Int + + """The subsite path to update.""" + subsitePath: String +} + +"""The result of updating a subsite domain.""" +type AppEnvironmentUpdateSubsiteDomainPayload { + """The application that owns the environment.""" + app: App + + """The domain assigned to the subsite.""" + domain: Domain + + """The updated environment.""" + environment: AppEnvironment +} + +"""The current status of a subsite domain update.""" +type AppEnvironmentUpdateSubsiteDomainStatus { + """Whether a database operation is currently in progress.""" + dbOperationInProgress: Boolean + + """Detailed progress information for the update.""" + progress: AppEnvironmentStatusProgress + + """Whether a subsite domain update is currently in progress.""" + updateSubsiteDomainInProgress: Boolean +} + +"""The strategies available for running WP-CLI commands.""" +enum AppEnvironmentWPCliStrategy { + """Run WP-CLI over SSH.""" + ssh + + """Run WP-CLI over a websocket connection.""" + websocket +} + +"""A backup available for an environment.""" +type Backup { + """When the backup was created.""" + createdAt: String + + """The partitioning dataset associated with the backup, if any.""" + dataset: DBPartitioningDataset + + """The environment ID the backup belongs to.""" + environmentId: Int + + """The backup filename.""" + filename: String + + """The unique identifier for the backup.""" + id: Float + + """The backup size in bytes.""" + size: Float + + """The SQL dump tool used to generate the backup.""" + sqlDumpTool: String + + """The backup type.""" + type: String +} + +"""The available backup shipping schedules.""" +enum BackupShippingSchedule { + """Ship backups once per day.""" + Daily + + """Ship backups once per hour.""" + Hourly +} + +"""A paginated list of backups.""" +type BackupsList { + """The cursor for the next page of backups.""" + nextCursor: String + + """The backups returned in the current page.""" + nodes: [Backup] + + """The total number of matching backups.""" + total: Int +} + +"""Build configuration for the environment""" +type BuildConfiguration { + """Build type""" + buildType: String! + + """Node.js build environment variables""" + nodeBuildDockerEnv: String! + + """Node.js version""" + nodeJSVersion: String! + + """npm token""" + npmToken: String +} + +"""Variables for the Cancel WP-CLI Command mutation""" +input CancelWPCLICommandInput { + """The unique ID for the running command""" + guid: String +} + +"""Response from the Cancel WP-CLI Command mutation""" +type CancelWPCLICommandPayload { + """The command that was cancelled""" + command: WPCLICommand +} + +"""The log streams available for cloud shipping.""" +enum CloudShippingLogsType { + """Edge logs.""" + edge + + """Origin PHP-FPM logs.""" + origin_php_fpm + + """Origin slow query logs.""" + origin_slowlog + + """Origin Nginx logs.""" + origin_nginx + + """Origin log2logstash logs.""" + origin_log2logstash + + """Origin WP-Cron runner logs.""" + origin_wp_cron_runner + + """Origin Node.js logs.""" + origin_nodejs +} + +"""Azure Blob Storage configuration.""" +type CloudShippingObjectStorageConfigAzure { + """The Azure storage account name.""" + azure_account: String! + + """The Azure container name.""" + azure_container: String! + + """The Azure SAS token.""" + azure_sas_token: String! +} + +"""Azure Blob Storage input configuration for cloud shipping.""" +input CloudShippingObjectStorageConfigAzureInput { + """The Azure storage account name.""" + azure_account: String! + + """The Azure container name.""" + azure_container: String! + + """The Azure SAS token.""" + azure_sas_token: String! +} + +"""Google Cloud Storage configuration.""" +type CloudShippingObjectStorageConfigGCP { + """The GCP bucket name.""" + gcp_bucket: String! + + """The GCP credentials JSON.""" + gcp_credentials_json: String! +} + +"""Google Cloud Storage input configuration for cloud shipping.""" +input CloudShippingObjectStorageConfigGCPInput { + """The GCP bucket name.""" + gcp_bucket: String! + + """The GCP credentials JSON.""" + gcp_credentials_json: String! +} + +"""Amazon S3 object storage configuration.""" +type CloudShippingObjectStorageConfigS3 { + """The AWS account ID.""" + aws_account_id: String + + """The S3 bucket name.""" + s3_bucket: String! + + """The S3 region.""" + s3_region: String! + + """The IAM role used for shipping.""" + s3_shipper_role: String +} + +"""Amazon S3 input configuration for cloud shipping.""" +input CloudShippingObjectStorageConfigS3Input { + """The AWS account ID.""" + aws_account_id: String + + """The S3 bucket name.""" + s3_bucket: String! + + """The S3 region.""" + s3_region: String! + + """The IAM role used for shipping.""" + s3_shipper_role: String +} + +"""The object storage providers supported for cloud shipping.""" +enum CloudShippingObjectStorageProviders { + """Amazon S3.""" + aws_s3 + + """Google Cloud Storage.""" + gcp_cloud_storage + + """Azure Blob Storage.""" + azure_blob_storage +} + +"""Variables for the CodebaseChangeRepo mutation""" +input CodebaseChangeRepoInput { + """The unique ID of the Application""" + appId: Int! + + """The new branch name""" + branch: String! + + """The unique ID of the Environment""" + environmentId: Int! +} + +"""The result of a repository change request.""" +type CodebaseChangeRepoResult { + """A machine-readable result code for the repository change.""" + code: String + + """A human-readable message about the repository change.""" + message: String! + + """Whether the repository change succeeded.""" + success: Boolean! +} + +"""Codebase information for an environment.""" +type CodebaseInfo { + """Plugin maintenance details for the codebase.""" + plugins: CodebasePlugins! +} + +"""Codebase plugin maintenance information for an environment.""" +type CodebasePlugins { + """The pull requests created for plugin updates.""" + pullRequests: [CodebasePullRequest!]! + + """The maintenance tasks associated with plugin updates.""" + tasks: [CodebaseTask!]! + + """The vulnerabilities detected in plugins.""" + vulnerabilities: [CodebaseVulnerability!]! +} + +"""A pull request associated with a codebase update.""" +type CodebasePullRequest { + """The URL for the pull request.""" + link: String! + + """The plugin or module path being updated.""" + modulePath: String! + + """The target version in the pull request.""" + version: String! +} + +"""A task associated with a codebase update.""" +type CodebaseTask { + """When the task was last updated.""" + dateUpdated: String! + + """The reason the task failed, if any.""" + failureReason: String! + + """The plugin or module path the task applies to.""" + modulePath: String! + + """The current task status.""" + status: String! +} + +"""Variables for the CodebaseUpdatePlugin mutation""" +input CodebaseUpdatePluginInput { + """The unique ID of the Application""" + appId: Int! + + """The download link for the new plugin version""" + download: String + + """The unique ID of the Environment""" + environmentId: Int! + + """The location of the plugin in the codebase""" + location: String + + """The marketplace the plugin belongs too""" + marketplace: String + + """The name of the plugin""" + name: String + + """The plugin slug""" + slug: String! + + """The new version to update the plugin""" + version: String + + """The number of active vulns on the plugin""" + vulnCount: Int +} + +"""The result of a plugin update request.""" +type CodebaseUpdatePluginResult { + """The result code for the plugin update request.""" + code: String! + + """A human-readable message about the plugin update request.""" + message: String! + + """The status of the plugin update request.""" + status: String! +} + +"""A vulnerability found in the application codebase.""" +type CodebaseVulnerability { + """The URL with more information about the vulnerability.""" + link: String! + + """The plugin or module path affected by the vulnerability.""" + modulePath: String! + + """The severity label for the vulnerability.""" + severity: String! + + """The severity score for the vulnerability.""" + severityScore: String +} + +"""Input for creating an edge worker.""" +input CreateEdgeWorkerInput { + """The environment to create the worker on.""" + environmentId: Int! + + """An optional rule scoping which requests the worker runs on.""" + location: EdgeWorkerLocationInput + + """The human-readable name of the edge worker.""" + name: String! + + """The behavior to apply when the worker errors at runtime.""" + onFailure: EdgeWorkerOnFailure + + """The original source code to store for reference.""" + source: String + + """The base64-encoded compiled WASM binary.""" + wasmBinary: String! +} + +"""The custom error page configuration for an environment.""" +type CustomErrorPageConfig { + """The custom error page content stored in the API, when applicable.""" + content: String + + """The strategy used to source the custom error page.""" + strategy: CustomErrorPageConfigStrategy! + + """Suggested custom error page content found in the connected repository.""" + suggestedContentFromRepo: String +} + +"""The available strategies for serving a custom error page.""" +enum CustomErrorPageConfigStrategy { + """Serve the default VIP error page.""" + VIP_DEFAULT + + """Serve a custom error page sourced from the repository.""" + CUSTOM_FROM_REPOSITORY + + """Serve a custom error page stored through the API.""" + CUSTOM_FROM_API +} + +"""A copied database backup available for download.""" +type DBBackupCopy implements Model { + """The configuration used to create the backup copy.""" + config: DBBackupCopyConfig + + """The file path for the copied backup.""" + filePath: String! + + """ + id is not implemented by DBBackupCopy as it does not have an integer id + """ + id: Int +} + +"""The configuration used for a copied database backup.""" +type DBBackupCopyConfig { + """The label assigned to the backup copy.""" + backupLabel: String! + + """The optional network site ID included in the backup copy.""" + networkSiteId: Int + + """The site ID the backup copy belongs to.""" + siteId: Int! + + """The database tables included in the backup copy.""" + tables: [String!]! + + """The user ID that requested the backup copy.""" + userId: String +} + +"""A paginated list of copied database backups.""" +type DBBackupCopyList implements ModelList { + """The cursor for the next page of backup copies.""" + nextCursor: String + + """The backup copies returned in the current page.""" + nodes: [DBBackupCopy!]! + + """The total number of backup copies.""" + total: Int! +} + +"""Input for deleting an edge worker.""" +input DeleteEdgeWorkerInput { + """The identifier of the edge worker to delete.""" + edgeWorkerId: Int! + + """The environment the worker belongs to.""" + environmentId: Int! +} + +"""Input for deleting an identity provider.""" +input DeleteIdentityProviderInput { + """The identity provider ID to delete.""" + id: Int! + + """The organization ID the identity provider belongs to.""" + organizationId: Int! +} + +"""The result of deleting an identity provider.""" +type DeleteIdentityProviderPayload { + """Whether the identity provider was deleted.""" + deleted: Boolean +} + +"""A domain for an environment""" +type Domain { + """Is the domain currently active?""" + active: Boolean + + """The active certificate of the domain""" + certificate: Certificate + + """The matching certificates of the domain""" + certificates( + """The pagination cursor to continue from.""" + after: String + + """The maximum number of certificates to return.""" + first: Int + ): CertificateList + + """The date the domain was added to the system""" + createdAt: String + + """What is the IP of the domain and does it point to VIP?""" + dns: DomainDNSRecord + + """When was the email deliverability last checked?""" + emailDeliverabilityLastCheckedAt: String + + """The environment this domain belongs to""" + environment: AppEnvironment + + """ + Does this domain have a valid TLS certificate? (Note: SSL is a misnomer there; we are using TLS certificates.) + """ + hasSSL: Boolean + + """The unique ID for the domain""" + id: Int + + """Is this a default domain? (*.go-vip.co / *.go-vip.net)""" + isDefault: Boolean + + """Is the DKIM record valid?""" + isDkimValid: Boolean + + """Is the DMARC record valid?""" + isDmarcValid: Boolean + + """Is the domain using a Let's Encrypt certificate""" + isLetsEncrypt: Boolean + + """Is this the primary domain for the environment?""" + isPrimary: Boolean + + """Is the SPF record valid?""" + isSpfValid: Boolean + + """Is the domain ownership verified?""" + isVerified: Boolean + + """What are the issues that may block LE provisioning for this domain?""" + letsEncryptCompatibility: [DomainLetsEncryptCompatibility] + + """What is the status of LE provisioning?""" + letsEncryptStatus: [DomainLetsEncryptStatus] + + """The domain name (i.e. something like example.com or sub.example.com)""" + name: String! + + """The generated TXT record for the domain""" + verificationCode: String + + """The wildcard value for the current domain""" + wildcard: String +} + +"""DNS details for a domain.""" +type DomainDNSRecord { + """Whether VIP response headers were observed for the domain.""" + hasVIPHeaders: Boolean + + """The resolved IP addresses for the domain.""" + ip: [String] + + """Whether the domain points to VIP.""" + isVIP: Boolean +} + +"""A compatibility issue that can block Let's Encrypt provisioning.""" +type DomainLetsEncryptCompatibility { + """Recommended action to resolve the issue.""" + actionable: String + + """A machine-readable compatibility code.""" + code: String + + """The affected domain.""" + domain: String + + """An explanation of the compatibility issue.""" + explanation: String + + """Whether the issue is DNS-related.""" + isDNSIssue: Boolean + + """Whether the issue is fatal.""" + isFatal: Boolean + + """A short title for the compatibility issue.""" + title: String +} + +"""The current Let's Encrypt provisioning status for a domain.""" +type DomainLetsEncryptStatus { + """Whether the status indicates a broken state.""" + broken: Boolean + + """The latest error message, if any.""" + errorMessage: String + + """The certificate expiration date.""" + expirationDate: String + + """The number of failures recorded.""" + failCount: Int + + """When the latest error occurred.""" + lastErrorDateTime: String + + """The status name.""" + name: String + + """When the next retry is scheduled.""" + retryDate: String +} + +"""A paginated list of domains.""" +type DomainList { + """The cursor for the next page of domains.""" + nextCursor: String + + """The domains returned in the current page.""" + nodes: [Domain] + + """The total number of matching domains.""" + total: Int +} + +"""Edge configuration for an environment.""" +type EdgeConfig { + """The access restriction settings.""" + accessRestrictions: EdgeConfigAccessRestrictions! +} + +"""Access restriction settings applied at the edge.""" +type EdgeConfigAccessRestrictions { + """The IP-based access restrictions.""" + ip: EdgeConfigAccessRestrictionsIp + + """The user-agent-based access restrictions.""" + userAgent: EdgeConfigAccessRestrictionsUserAgent +} + +"""IP-based access restriction configuration.""" +type EdgeConfigAccessRestrictionsIp { + """The action to apply to matching IPs.""" + action: EdgeConfigAccessRestrictionsIpAction! + + """The IP groups included in the restriction.""" + groups: [EdgeConfigAccessRestrictionsIpGroup!]! +} + +"""The actions available for IP access restrictions.""" +enum EdgeConfigAccessRestrictionsIpAction { + """Allow matching IPs.""" + allow + + """Deny matching IPs.""" + deny +} + +"""A group of IP access restriction rules.""" +type EdgeConfigAccessRestrictionsIpGroup { + """When the group was created.""" + createdAt: Date! + + """The unique identifier for the group.""" + id: String! + + """The IPs included in the group.""" + ips: [String]! + + """Notes describing the group.""" + notes: String! + + """When the group was last updated.""" + updatedAt: Date! +} + +"""Input for an IP access restriction group.""" +input EdgeConfigAccessRestrictionsIpGroupInput { + """The group ID when updating an existing group.""" + id: String + + """The IPs included in the group.""" + ips: [String]! + + """Notes describing the group.""" + notes: String! +} + +"""User-agent-based access restriction configuration.""" +type EdgeConfigAccessRestrictionsUserAgent { + """The user-agent groups included in the restriction.""" + groups: [EdgeConfigAccessRestrictionsUserAgentGroup] +} + +"""A group of user-agent access restriction rules.""" +type EdgeConfigAccessRestrictionsUserAgentGroup { + """When the group was created.""" + createdAt: Date! + + """The unique identifier for the group.""" + id: String! + + """Notes describing the group.""" + notes: String! + + """The matching rules included in the group.""" + rules: [EdgeConfigAccessRestrictionsUserAgentRule!]! + + """When the group was last updated.""" + updatedAt: Date! +} + +"""The operators available for user-agent access restriction rules.""" +enum EdgeConfigAccessRestrictionsUserAgentOperator { + """Match when the user agent contains the value.""" + contains + + """Match when the user agent exactly equals the value.""" + equals +} + +"""A single user-agent access restriction rule.""" +type EdgeConfigAccessRestrictionsUserAgentRule { + """The operator used to match the user agent.""" + operator: EdgeConfigAccessRestrictionsUserAgentOperator! + + """The value to compare the user agent against.""" + value: String! +} + +"""Input for updating IP access restrictions.""" +input EdgeConfigUpdateIPAccessRestrictionsInput { + """The action to apply to matching IPs.""" + action: EdgeConfigAccessRestrictionsIpAction! + + """The environment ID to update.""" + environmentId: Int! + + """The IP groups to store.""" + groups: [EdgeConfigAccessRestrictionsIpGroupInput]! +} + +"""Input for updating user-agent access restrictions.""" +input EdgeConfigUpdateUserAgentAccessRestrictionsInput { + """The environment ID to update.""" + environmentId: Int! + + """The user-agent groups to store.""" + groups: [EdgeConfigUpdateUserAgentGroupInput!]! +} + +"""Input for a user-agent access restriction group.""" +input EdgeConfigUpdateUserAgentGroupInput { + """The group ID when updating an existing group.""" + id: String + + """Notes describing the group.""" + notes: String! + + """The matching rules included in the group.""" + rules: [EdgeConfigUpdateUserAgentGroupRuleInput!]! +} + +"""Input for a user-agent access restriction rule.""" +input EdgeConfigUpdateUserAgentGroupRuleInput { + """The operator used to match the user agent.""" + operator: EdgeConfigAccessRestrictionsUserAgentOperator! + + """The value to compare the user agent against.""" + value: String! +} + +"""A WASM edge worker deployed to an environment.""" +type EdgeWorker { + """Whether the worker is currently active.""" + active: Boolean! + + """When the worker was created.""" + createdAt: Date! + + """The unique identifier for the edge worker.""" + id: Int! + + """ + An optional rule scoping which requests the worker runs on. Runs on all requests when null. + """ + location: EdgeWorkerLocation + + """The human-readable name of the edge worker.""" + name: String! + + """The behavior to apply when the worker errors at runtime.""" + onFailure: EdgeWorkerOnFailure! + + """The request lifecycle phases the worker runs in.""" + phases: [EdgeWorkerPhase!]! + + """The original source code, if it was stored. Fetched on demand.""" + source: String + + """When the worker was last modified.""" + updatedAt: Date! + + """The base64-encoded compiled WASM binary. Fetched on demand.""" + wasmBinary: String +} + +"""A rule scoping which requests an edge worker runs on.""" +type EdgeWorkerLocation { + """The operator used to match the request path.""" + operator: EdgeWorkerLocationOperator! + + """The value to compare the request path against.""" + value: String! +} + +"""Input for an edge worker location rule.""" +input EdgeWorkerLocationInput { + """The operator used to match the request path.""" + operator: EdgeWorkerLocationOperator! + + """The value to compare the request path against.""" + value: String! +} + +"""The operators available for matching an edge worker location.""" +enum EdgeWorkerLocationOperator { + """Match when the path contains the value.""" + contains + + """Match when the path exactly equals the value.""" + equals + + """Match when the path starts with the value.""" + starts_with + + """Match when the path ends with the value.""" + ends_with +} + +"""The behavior to apply when an edge worker errors at runtime.""" +enum EdgeWorkerOnFailure { + """Continue serving the request as if the worker had not run.""" + continue + + """Fail the request when the worker errors.""" + error +} + +"""The request lifecycle phases an edge worker can run in.""" +enum EdgeWorkerPhase { + """Run while the request is being processed.""" + request + + """Run while the response is being processed.""" + response +} + +"""Input for enabling or disabling identity provider encryption.""" +input EnableIdentityProviderEncryptionInput { + """The identity provider ID to update.""" + identityProviderId: Int + + """The organization ID the identity provider belongs to.""" + organizationId: Int! +} + +"""The result of enabling or disabling identity provider encryption.""" +type EnableIdentityProviderEncryptionPayload { + """The updated identity provider.""" + identityProvider: IdentityProvider +} + +"""Input for enabling phpMyAdmin.""" +input EnablePhpMyAdminInput { + """The environment ID.""" + environmentId: Int! +} + +"""The result of enabling phpMyAdmin.""" +type EnablePhpMyAdminPayload { + """Whether phpMyAdmin was enabled successfully.""" + success: Boolean +} + +"""Customer-provided environment variable / constant""" +type EnvironmentVariable { + """Environment variable name""" + name: String! + + """Environment variable value""" + value: String +} + +"""Input for creating, updating, or deleting an environment variable.""" +input EnvironmentVariableInput { + """The unique ID of the Application""" + applicationId: Int! + + """The unique ID of the environment""" + environmentId: Int! + + """ + Environment variable name (must consist of uppercase letters, numbers, and underscore + """ + name: String! + + """Whether to reload the site manifest after the operation""" + reloadManifest: Boolean + + """Environment variable value""" + value: String! +} + +"""Customer-provided environment variables / constants""" +type EnvironmentVariablesList { + """The environment variables for this environment""" + nodes: [EnvironmentVariable] + + """The total number of environment variables for this environment""" + total: BigInt +} + +"""The updated environment variable list after a mutation.""" +type EnvironmentVariablesPayload { + """The environment variables currently configured on the environment.""" + environmentVariables: EnvironmentVariablesList +} + +"""Input for generating phpMyAdmin access.""" +input GeneratePhpMyAdminAccessInput { + """The environment ID.""" + environmentId: Int! +} + +"""The result of generating phpMyAdmin access.""" +type GeneratePhpMyAdminAccessPayload { + """When the phpMyAdmin access expires.""" + expiresAt: Date + + """The generated phpMyAdmin URL.""" + url: String +} + +"""An identity provider configured for an organization.""" +type IdentityProvider implements Model { + """Whether the identity provider is active.""" + active: Boolean + + """The callback URL for the identity provider.""" + callbackURL: String + + """The primary signing certificate.""" + certificate: String + + """The expiry date of the primary certificate.""" + certificateExpiryDate: String + + """When the identity provider was created.""" + createdAt: String + + """The dashboard login URL for the identity provider.""" + dashboardLoginURL: String + + """The display name of the identity provider.""" + displayName: String + + """The SAML entry point URL.""" + entryPoint: String + + """When the first successful login occurred.""" + firstSuccessfulLogin: String + + """The unique identifier for the identity provider.""" + id: Int + + """The issuer configured for the identity provider.""" + issuer: String + + """The raw metadata XML for the identity provider.""" + metadataXML: String + + """The organization ID the identity provider belongs to.""" + organizationId: Int + + """The provider type.""" + provider: String + + """The secondary signing certificate, if present.""" + secondaryCertificate: String + + """The expiry date of the secondary certificate.""" + secondaryCertificateExpiryDate: String + + """The expiry date of the signing certificate.""" + signingCertificateExpiryDate: String + + """The public key for encryption or signing.""" + signingCertificatePublicKey: String + + """The slug for the identity provider.""" + slug: String + + """When the identity provider was last updated.""" + updatedAt: String +} + +"""A paginated list of identity providers.""" +type IdentityProviderList implements ModelList { + """The cursor for the next page of identity providers.""" + nextCursor: String + + """The identity providers returned in the current page.""" + nodes: [IdentityProvider] + + """The total number of matching identity providers.""" + total: Int +} + +"""A live backup copy created for an environment.""" +type LiveBackupCopy { + """The configuration used to create the copy.""" + config: LiveBackupCopyConfig! + + """The unique identifier for the copy.""" + copyId: String! + + """When the copy was created.""" + createdAt: Date! + + """The error message, if the copy failed.""" + error: String + + """When the copy expires.""" + expiresAt: Date + + """When the copy finished.""" + finishedAt: Date + + """The size of the copy in bytes.""" + size: BigInt + + """The current status of the copy.""" + status: LiveBackupCopyStatus! +} + +"""The configuration used for a live backup copy.""" +type LiveBackupCopyConfig { + """The subsite IDs included in the copy.""" + subsiteIds: [Int!] + + """The table configuration for the copy.""" + tables: [LiveBackupCopyTableConfig!] + + """The tool used to create the copy.""" + tool: LiveBackupCopyTool! + + """The type of live backup copy.""" + type: LiveBackupCopyType! + + """The WP-CLI command used to generate the copy.""" + wpcliCommand: String +} + +"""Input for starting a live backup copy.""" +input LiveBackupCopyConfigInput { + """The live backup copy configuration payload.""" + config: JSON + + """The environment ID.""" + environmentId: Int! + + """The application ID.""" + id: Int! +} + +"""The statuses of a live backup copy.""" +enum LiveBackupCopyStatus { + """The copy is pending.""" + pending + + """The copy is currently in progress.""" + in_progress + + """The copy completed successfully.""" + completed + + """The copy failed.""" + failed +} + +"""A table configuration for a live backup copy.""" +type LiveBackupCopyTableConfig { + """The options applied to the table.""" + options: [LiveBackupCopyTableOptionConfig!] + + """The table name.""" + table: String! +} + +"""An option applied to a table in a live backup copy.""" +type LiveBackupCopyTableOptionConfig { + """The option key.""" + key: String! + + """The option value.""" + value: String! +} + +"""The tools available for live backup copies.""" +enum LiveBackupCopyTool { + """Use `mysqldump` to create the copy.""" + mysqldump + + """Use `mydumper` to create the copy.""" + mydumper +} + +"""The supported live backup copy modes.""" +enum LiveBackupCopyType { + """Copy the full database.""" + full + + """Copy selected tables.""" + tables + + """Copy selected subsite IDs.""" + subsite_ids + + """Copy data selected by a WP-CLI command.""" + wpcli_command +} + +"""A media export generated for an environment.""" +type MediaExport { + """When the export was created.""" + createdAt: String + + """The environment ID the media export belongs to.""" + environmentId: Int + + """Any error details for the export.""" + error: MediaExportError + + """When the export expires.""" + expiresAt: String + + """The number of files processed so far.""" + filesProcessed: Int + + """The total number of files in the export.""" + filesTotal: Int + + """The unique identifier for the media export.""" + id: BigInt + + """The current export status.""" + status: String + + """The subsite included in the export, if any.""" + subsite: WPSite + + """The total number of archive files generated.""" + totalArchiveFiles: Int + + """The total size of the export in bytes.""" + totalSizeInBytes: Float + + """The user who started the export.""" + user: WPCLICommandUser +} + +"""Error details for a media export.""" +type MediaExportError { + """Global errors that apply to the whole export.""" + globalErrors: [String] + + """Whether the export includes file-level errors.""" + hasFileErrors: Boolean +} + +"""A paginated list of media exports.""" +type MediaExportsList { + """The cursor for the next page of media exports.""" + nextCursor: String + + """The media exports returned in the current page.""" + nodes: [MediaExport] + + """The total number of matching media exports.""" + total: Int +} + +"""Input for adding a new domain.""" +input NewDomain { + """The domain name to add.""" + name: String! +} + +"""A list of billable request statistics for an organization.""" +type OrgRequestStatsList { + """The request statistics rows.""" + nodes: [SiteRequestStat]! + + """The total number of statistics rows returned.""" + total: BigInt! +} + +"""An authentication domain configured for an organization.""" +type OrganizationAuthDomain implements Model { + """Whether the auth domain is active.""" + active: Boolean + + """When the auth domain was created.""" + createdAt: String + + """The domain value.""" + domain: String + + """The unique identifier for the auth domain.""" + id: Int + + """The organization ID the auth domain belongs to.""" + organizationId: Int +} + +"""Input for creating or updating an organization auth domain.""" +input OrganizationAuthDomainCreateInput { + """Whether the auth domain should be active.""" + active: Boolean + + """The domain value to save.""" + domain: String! + + """The auth domain ID when updating an existing record.""" + id: Int + + """The organization ID the auth domain belongs to.""" + organizationId: Int! +} + +"""Input for deleting an organization auth domain.""" +input OrganizationAuthDomainDeleteInput { + """The auth domain ID to delete.""" + id: Int! +} + +"""The result of deleting an organization auth domain.""" +type OrganizationAuthDomainDeletePayload { + """Whether the auth domain was deleted.""" + deleted: Boolean +} + +"""A paginated list of organization auth domains.""" +type OrganizationAuthDomainList implements ModelList { + """The cursor for the next page of auth domains.""" + nextCursor: String + + """The auth domains returned in the current page.""" + nodes: [OrganizationAuthDomain] + + """The total number of auth domains.""" + total: Int +} + +"""The result of saving an organization auth domain.""" +type OrganizationAuthDomainPayload { + """The saved auth domain.""" + authDomain: OrganizationAuthDomain +} + +"""Input for replacing all auth domains on an organization.""" +input OrganizationAuthDomainReplaceInput { + """The complete list of domains to store.""" + domains: [String!]! + + """The organization ID whose auth domains should be replaced.""" + organizationId: Int! +} + +"""The result of replacing an organization's auth domains.""" +type OrganizationAuthDomainReplacePayload { + """The auth domains after replacement.""" + authDomains: [OrganizationAuthDomain] + + """The organization whose auth domains were replaced.""" + organization: Organization +} + +"""The phpMyAdmin status for an environment.""" +type PHPMyAdminStatus { + """The current phpMyAdmin status value.""" + status: String +} + +"""Input for requesting a feature upgrade.""" +input RequestFeatureUpgradeInput { + """The optional application ID the upgrade applies to.""" + appId: Int + + """The feature being requested.""" + feature: String! + + """The organization ID requesting the upgrade.""" + organizationId: Int! +} + +"""The result of a feature upgrade request.""" +type RequestFeatureUpgradePayload { + """Whether the feature upgrade request was accepted.""" + success: Boolean +} + +"""Request statistics for an application environment.""" +type RequestStats { + """The number of Automattic-cached API requests.""" + apiA8cCached: BigInt + + """The number of Automattic-uncached API requests.""" + apiA8cUncached: BigInt + + """The number of cached API requests.""" + apiCached: BigInt + + """The number of uncached API requests.""" + apiUncached: BigInt + + """The number of Automattic-cached application requests.""" + appA8cCached: BigInt + + """The number of Automattic-uncached application requests.""" + appA8cUncached: BigInt + + """The number of cached application requests.""" + appCached: BigInt + + """The number of uncached application requests.""" + appUncached: BigInt + + """When the statistics row was created.""" + createdAt: String + + """The date the statistics apply to.""" + date: String + + """The environment ID the statistics belong to.""" + environmentId: Int + + """The unique identifier for the request statistics row.""" + id: Int + + """The number of Automattic-cached static asset requests.""" + staticA8cCached: BigInt + + """The number of Automattic-uncached static asset requests.""" + staticA8cUncached: BigInt + + """The number of cached static asset requests.""" + staticCached: BigInt + + """The number of uncached static asset requests.""" + staticUncached: BigInt +} + +"""A list of request statistics rows.""" +type RequestStatsList { + """The request statistics rows.""" + nodes: [RequestStats] + + """The total number of request statistics rows.""" + total: Int +} + +"""Input for creating or updating an identity provider.""" +input SaveIdentityProviderInput { + """Whether the identity provider should be active.""" + active: Boolean! + + """The primary signing certificate.""" + certificate: String! + + """The display name of the identity provider.""" + displayName: String + + """The SAML entry point URL.""" + entryPoint: String + + """The identity provider ID when updating.""" + id: Int + + """The issuer configured for the identity provider.""" + issuer: String + + """The organization ID the identity provider belongs to.""" + organizationId: Int! + + """The provider type.""" + provider: String! + + """The secondary signing certificate, if present.""" + secondaryCertificate: String + + """The slug for the identity provider.""" + slug: String +} + +"""The result of saving an identity provider.""" +type SaveIdentityProviderPayload { + """The saved identity provider.""" + identityProvider: IdentityProvider +} + +"""Input for enabling or disabling an edge worker.""" +input SetEdgeWorkerActiveInput { + """Whether the worker should be active.""" + active: Boolean! + + """The identifier of the edge worker to toggle.""" + edgeWorkerId: Int! + + """The environment the worker belongs to.""" + environmentId: Int! +} + +"""Input for updating identity provider validation settings.""" +input SetIdentityProviderValidationsInput { + """The identity provider ID to update.""" + id: Int! + + """The organization ID the identity provider belongs to.""" + organizationId: Int! + + """Whether to validate the SAML audience.""" + validateAudience: Boolean! + + """Whether SAML assertions must be signed.""" + wantAssertionsSigned: Boolean! + + """Whether AuthnResponse documents must be signed.""" + wantAuthnResponseSigned: Boolean! +} + +"""The result of updating identity provider validation settings.""" +type SetIdentityProviderValidationsPayload { + """The identity provider ID that was updated.""" + id: Int! + + """The organization ID the identity provider belongs to.""" + organizationId: Int! + + """Whether audience validation is enabled.""" + validateAudience: Boolean! + + """Whether assertion signing is required.""" + wantAssertionsSigned: Boolean! + + """Whether AuthnResponse signing is required.""" + wantAuthnResponseSigned: Boolean! +} + +"""Billable request statistics for a site.""" +type SiteRequestStat { + """The billable API request count for the selected period.""" + billableApiRequestCount: BigInt! + + """The billable application request count for the selected period.""" + billableAppRequestCount: BigInt! + + """The site ID the request statistics belong to.""" + clientSiteId: BigInt! + + """The daily billable API request count for the site.""" + dailyBillableApiRequestCount: BigInt! + + """The daily billable application request count for the site.""" + dailyBillableAppRequestCount: BigInt! + + """The date for the request statistics.""" + date: String! + + """The aggregation resolution used for the statistics.""" + resolution: String! +} + +"""Configuration options for starting a media export.""" +input StartMediaExportConfigOptions { + """A regex used to filter exported files.""" + regex: String + + """The subsite ID to export media from.""" + subsiteId: Int +} + +"""Input for starting a media export.""" +input StartMediaExportInput { + """The application ID that owns the environment.""" + appId: Int + + """The export configuration options.""" + config: StartMediaExportConfigOptions + + """The environment ID to export media from.""" + environmentId: Int +} + +"""The result of starting a media export.""" +type StartMediaExportPayload { + """The media export that was created.""" + mediaExport: MediaExport + + """A human-readable message about the export request.""" + message: String + + """Whether the export request succeeded.""" + success: Boolean +} + +"""Visitor counts for a single date.""" +type Stats { + """The daily unique visitors count for the date.""" + dailyUniqueVisitorsCount: Int + + """The date the visitor counts apply to.""" + date: String! + + """The monthly unique visitors count for the date.""" + monthlyUniqueVisitorsCount: Int! +} + +"""A list of visitor count rows.""" +type StatsList { + """The visitor count rows.""" + nodes: [Stats]! + + """The total number of visitor count rows returned.""" + total: BigInt! +} + +"""The result of verifying a Tollbit domain.""" +type TollbitDomainVerificationResult { + """The domain that was verified.""" + domain: String + + """An error returned during verification, if any.""" + error: String + + """Whether the domain was verified successfully.""" + isVerified: Boolean +} + +"""Input for triggering an Agentforce sync""" +input TriggerAgentforceSyncInput { + """The unique ID of the Application""" + applicationId: Int! + + """The unique ID of the Environment""" + environmentId: Int! + + """Network site ID for multisite - specifies which subsite to sync""" + networkSiteId: Int + + """Deprecated: use networkSiteId for multisite sync targeting""" + url: String @deprecated(reason: "Use networkSiteId instead") +} + +"""Response payload for triggering an Agentforce sync""" +type TriggerAgentforceSyncPayload { + """ISO 8601 timestamp when the sync completed""" + completedAt: String + + """Number of items deleted""" + deleted: Int + + """Error message if the sync failed""" + error: String + + """Number of items that failed to sync""" + failed: Int + + """ID of the last post processed""" + lastPostId: Int + + """Human-readable status message""" + message: String + + """Raw output from the WP-CLI sync command (backward compatibility)""" + output: String! + + """Completion percentage (0-100)""" + percentage: Float + + """List of post types included in the sync""" + postTypes: [String!] + + """Number of items processed so far""" + processed: Int + + """Number of items skipped""" + skipped: Int + + """ISO 8601 timestamp when the sync started""" + startedAt: String + + """Current status of the sync operation""" + status: String + + """Whether the sync operation was successful""" + success: Boolean + + """Number of items successfully synced""" + synced: Int + + """Total number of items to process""" + total: Int + + """ISO 8601 timestamp when the sync was last updated""" + updatedAt: String +} + +"""Input for updating an environment's custom error page configuration.""" +input UpdateCustomErrorPageConfigInput { + """The custom error page content to store when using the API strategy.""" + content: String + + """The environment ID to update.""" + environmentId: Int! + + """The strategy to apply.""" + strategy: CustomErrorPageConfigStrategy! +} + +"""Input for updating an edge worker.""" +input UpdateEdgeWorkerInput { + """The identifier of the edge worker to update.""" + edgeWorkerId: Int! + + """The environment the worker belongs to.""" + environmentId: Int! + + """A new rule scoping which requests the worker runs on.""" + location: EdgeWorkerLocationInput + + """A new human-readable name for the edge worker.""" + name: String + + """The behavior to apply when the worker errors at runtime.""" + onFailure: EdgeWorkerOnFailure + + """New source code to store for reference.""" + source: String + + """A new base64-encoded compiled WASM binary. Re-validated when provided.""" + wasmBinary: String +} + +"""The result of validating phpMyAdmin access.""" +type ValidatePhpMyAdminAccessPayload { + """Whether phpMyAdmin access is valid.""" + success: Boolean +} + +"""Input for verifying a DNS TXT record.""" +input VerifyDnsTxtRecordInput { + """The domain ID to verify.""" + id: Int +} + +"""The result of verifying a DNS TXT record.""" +type VerifyDnsTxtRecordPayload { + """Whether the TXT record is valid.""" + valid: Boolean +} + +"""Visitor statistics for a Parse.ly site.""" +type VisitorsStats { + """The Parse.ly site ID the statistics belong to.""" + parselySiteId: String! + + """The visitor statistics for the site.""" + stats: StatsList! +} + +"""A list of Parse.ly visitor statistics.""" +type VisitorsStatsList { + """The visitor statistics entries.""" + nodes: [VisitorsStats]! + + """The total number of sites returned.""" + total: BigInt! +} + +"""A WP-CLI command executed on an application environment.""" +type WPCLICommand { + """The WP-CLI command that was executed.""" + command: String + + """When the command was created.""" + createdAt: String + + """When the command ended.""" + endedAt: String + + """The environment ID the command ran on.""" + environmentId: Int + + """The GUID for the command.""" + guid: String + + """The unique identifier for the command.""" + id: Int + + """When the command started.""" + startedAt: String + + """The current status of the command.""" + status: String + + """The user that triggered the command.""" + user: WPCLICommandUser + + """The user ID that triggered the command.""" + userId: Int +} + +"""A paginated list of WP-CLI commands.""" +type WPCLICommandList { + """The cursor for the next page of commands.""" + nextCursor: String + + """The commands returned in the current page.""" + nodes: [WPCLICommand] + + """The total number of matching commands.""" + total: Int +} + +"""The user who triggered a WP-CLI command.""" +type WPCLICommandUser { + """The display name of the user.""" + displayName: String + + """The user's GitHub username.""" + githubUsername: String + + """The unique identifier for the user.""" + id: Int + + """Whether the user is a VIP user.""" + isVIP: Boolean + + """The user's WordPress.com username.""" + wpcomUsername: String +} + +"""SSH credentials for running a WP-CLI command.""" +type WPCliSSHAuthentication { + """The SSH host.""" + host: String! + + """The passphrase for the private key.""" + passphrase: String! + + """The SSH port.""" + port: String! + + """The private key used for authentication.""" + privateKey: String! + + """The SSH username.""" + username: String! +} + +"""WordPress installation details for an application environment.""" +type WPInstallation { + """Core WordPress Site Installation Details""" + core: WPInstallationCoreDetails + + """App Environment Name""" + environmentName: String + + """Details about Jetpack""" + jetpack: WPInstallationJetpackDetails + + """Details about all plugins installed""" + plugins: [WPInstallationPluginDetails!] + + """Details about Security Boost""" + securityBoost: WPInstallationSecurityBoostDetails + + """App Environment / GOOP Site ID""" + siteId: Int + + """Last updated timestamp of the Site Installation Details""" + timestamp: BigInt +} + +"""Core metadata about a WordPress installation.""" +type WPInstallationCoreDetails { + """Is WordPress Multisite Installation""" + isMultisite: Boolean + + """WordPress Installation PHP Version""" + phpVersion: String + + """WordPress Installation Version""" + wpVersion: String +} + +"""Jetpack details for a WordPress installation.""" +type WPInstallationJetpackDetails { + """Is Jetpack available on WordPress Installation""" + available: Boolean + + """Jetpack Version""" + version: String + + """VIP Jetpack Version""" + vipVersion: String +} + +"""Plugin details reported for a WordPress installation.""" +type WPInstallationPluginDetails { + """WordPress Plugin activated by""" + activatedBy: String + + """Is WordPress Plugin active""" + active: Boolean! + + """WordPress Plugin update download link""" + downloadLink: String + + """WordPress Plugin available update version""" + hasUpdate: String + + """WordPress Plugin marketplace""" + marketplace: String + + """WordPress Plugin name""" + name: String! + + """WordPress Plugin path""" + path: String! + + """WordPress Plugin slug""" + slug: String + + """WordPress Plugin version""" + version: String! +} + +"""Security Boost details for a WordPress installation.""" +type WPInstallationSecurityBoostDetails { + """Inactive users count across all blogs""" + inactiveUsersCountAllBlogs: Int + + """Two factor authentication status""" + twoFactorStatus: WPInstallationTwoFactorStatus + + """Users without 2FA count across all blogs""" + usersWithout2faCountAllBlogs: Int +} + +""" +Two-factor authentication enforcement details for a WordPress installation. +""" +type WPInstallationTwoFactorStatus { + """Has enable two factor filter""" + hasEnableTwoFactorFilter: Boolean + + """Has two factor forced filter""" + hasTwoFactorForcedFilter: Boolean + + """Is enforced globally""" + isEnforcedGlobally: Boolean + + """Is entirely disabled""" + isEntirelyDisabled: Boolean + + """Is not enforced globally""" + isNotEnforcedGlobally: Boolean +} + +"""A WordPress site or subsite within an environment.""" +type WPSite { + """WordPress Site/Blog ID""" + blogId: Int + + """List of WordPress PHP defines/constants used in the blog""" + constants: [WPSitePhpConstants] + + """WordPress Home URL option""" + homeUrl: String + + """[DEPRECATING SOON] Alias for blogId""" + id: Int + + """WP Site Installation Details""" + installation: WPInstallation + + """[DEPRECATING SOON] Is blog active""" + isActive: Boolean + + """Jetpack Details""" + jetpack: WPSiteJetpackDetails + + """[DEPRECATING SOON] Alias for jetpack""" + jetpackDetails: WPSiteJetpackDetails + + """Launched status of the subsite""" + launchStatus: WPSiteLaunchStatus + + """Details about Parse.ly plugin (wp-parsely) usage""" + parsely: WPSiteParselyDetails + + """List of enabled plugins on the blog""" + plugins: [String] + + """WordPress Site URL option""" + siteUrl: String + + """Last updated timestamp of the Site Details""" + timestamp: BigInt +} + +"""Jetpack details for a WordPress site.""" +type WPSiteJetpackDetails { + """Is Jetpack Active""" + active: Boolean + + """[DEPRECATING SOON] Jetpack Cache Site ID""" + cacheSiteId: Int + + """Jetpack Cache Site ID""" + id: String + + """Enabled Jetpack modules""" + modules: [String] +} + +"""The launch states for a WordPress site.""" +enum WPSiteLaunchStatus { + """The site is launched.""" + LAUNCHED + + """The site is not launched.""" + NOT_LAUNCHED + + """The site is currently launching.""" + LAUNCHING + + """The site launch state is unknown.""" + UNKNOWN +} + +"""Variables for the UpdateWPSiteLaunchStatus mutation""" +input WPSiteLaunchStatusInput { + """Unique ID of the application""" + appId: Int! + + """Unique ID of the environment""" + environmentId: Int! + + """Updated launch status of the network site""" + launchStatus: WPSiteLaunchStatus! + + """ID of the network site (subsite) being updated""" + networkSiteId: Int! +} + +"""Variables for the UpdateWPSiteLaunchStatus mutation""" +type WPSiteLaunchStatusPayload { + """The application that owns the site.""" + app: App + + """The environment that owns the site.""" + environment: AppEnvironment + + """Updated launch status of the network site""" + launchStatus: String + + """ID of the network site (subsite) being updated""" + networkSiteId: Int +} + +"""A paginated list of WordPress sites.""" +type WPSiteList { + """The cursor for the next page of WordPress sites.""" + nextCursor: String + + """The WordPress sites returned in the current page.""" + nodes: [WPSite] + + """The total number of matching WordPress sites.""" + total: Int +} + +"""Parse.ly configuration values for a WordPress site.""" +type WPSiteParselyConfigs { + """Does the site have a Parse.ly API Secret configured?""" + haveApiSecret: Boolean + + """Is autotrack disabled (to allow Dynamic Tracking to be used)?""" + isAutotrackingDisabled: Boolean + + """Is JavaScript Tracking disabled?""" + isJavascriptDisabled: Boolean + + """Is the site pinned to the specific plugin version?""" + isPinnedVersion: Boolean + + """Is JavaScript tracking enabled for logged in users?""" + shouldTrackLoggedInUsers: Boolean + + """Parse.ly Site ID (aka apikey)""" + siteId: String + + """Details about tracked post types""" + trackedPostTypes: [WPSiteParselyTrackedPostTypesConfig] +} + +"""Parse.ly details for a WordPress site.""" +type WPSiteParselyDetails { + """Is wp-parsely active?""" + active: Boolean + + """Details about how the plugin is configured on site""" + configs: WPSiteParselyConfigs + + """How wp-parsely is activated (if active)""" + integrationType: String + + """Version for the wp-parsely plugin""" + version: String +} + +"""A tracked post type configuration for Parse.ly.""" +type WPSiteParselyTrackedPostTypesConfig { + """The slug for the post type""" + name: String + + """ + How is the post type tracked within Parse.ly? (post, non-post, or do-not-track) + """ + trackType: String +} + +"""A PHP constant defined for a WordPress site.""" +type WPSitePhpConstants { + """WordPress PHP Define/Constant key""" + name: String + + """WordPress PHP Define/Constant value""" + value: String +} + +"""The object storage configuration for cloud shipping.""" +union CloudShippingObjectStorageConfig = CloudShippingObjectStorageConfigS3 | CloudShippingObjectStorageConfigGCP | CloudShippingObjectStorageConfigAzure + +"""The result of enabling or disabling defensive mode.""" +type AppEnvironmentDefensiveModePayload { + """The application that owns the environment.""" + app: App + + """Whether defensive mode is enabled.""" + enabled: Boolean +} + +"""Input for selecting an object storage destination.""" +input ObjectStorageConfigInput { + """The object storage provider.""" + provider: CloudShippingObjectStorageProviders! + + """The S3 configuration, when using Amazon S3.""" + object_storage_config_s3: CloudShippingObjectStorageConfigS3Input + + """The GCP configuration, when using Google Cloud Storage.""" + object_storage_config_gcp: CloudShippingObjectStorageConfigGCPInput + + """The Azure configuration, when using Azure Blob Storage.""" + object_storage_config_azure: CloudShippingObjectStorageConfigAzureInput +} + +"""Input for deleting defensive mode configuration.""" +input AppEnvironmentDefensiveModeDeleteInput { + """The application ID.""" + id: Int! + + """The environment ID.""" + environmentId: Int! +} + +"""A single live backup copy table option.""" +input LiveBackupCopyTableOptionConfigInput { + """The option key.""" + key: String! + + """The option value.""" + value: String! +} + +"""Configuration for a single table in a live backup copy.""" +input LiveBackupCopyTableConfigInput { + """The table name.""" + table: String! + + """The table-specific options.""" + options: [LiveBackupCopyTableOptionConfigInput!] +} \ No newline at end of file From 426a1dead2efeffd045e3a9aae353dd4845c4e66 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 20 Aug 2026 18:36:31 -0500 Subject: [PATCH 05/32] feat(go): generated GraphQL bindings Generated by genqlient from the schema and operations in the previous commit. Regenerate with `make tidy-gql`; `make verify-gql-stale` fails CI if this file drifts from its inputs. Reviewers can skip this file. Ported from vip-cli-golang@72ef2f89 (feature/go-rewrite). --- internal/gql/generated.go | 8935 +++++++++++++++++++++++++++++++++++++ 1 file changed, 8935 insertions(+) create mode 100644 internal/gql/generated.go diff --git a/internal/gql/generated.go b/internal/gql/generated.go new file mode 100644 index 000000000..e7ace8cd5 --- /dev/null +++ b/internal/gql/generated.go @@ -0,0 +1,8935 @@ +// Code generated by github.com/Khan/genqlient, DO NOT EDIT. + +package gql + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Khan/genqlient/graphql" +) + +// AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload includes the requested fields of the GraphQL type AppEnvironmentAbortMediaImportPayload. +// The GraphQL type's documentation follows. +// +// Response payload for aborting a Media Import +type AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload struct { + // The unique ID of the Application + ApplicationId *int64 `json:"applicationId"` + // The unique ID of the Environment + EnvironmentId *int64 `json:"environmentId"` + // Media Import Abort Action Response + MediaImportStatusChange *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange `json:"mediaImportStatusChange"` +} + +// GetApplicationId returns AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload.ApplicationId, and is useful for accessing the field via an interface. +func (v *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload) GetApplicationId() *int64 { + return v.ApplicationId +} + +// GetEnvironmentId returns AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload) GetEnvironmentId() *int64 { + return v.EnvironmentId +} + +// GetMediaImportStatusChange returns AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload.MediaImportStatusChange, and is useful for accessing the field via an interface. +func (v *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload) GetMediaImportStatusChange() *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange { + return v.MediaImportStatusChange +} + +// AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange includes the requested fields of the GraphQL type AppEnvironmentMediaImportStatusChange. +// The GraphQL type's documentation follows. +// +// Response payload for executing a status change action on a Media Import +type AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange struct { + // Unique Identifier for a Media Import + ImportId *int64 `json:"importId"` + // Alias of environmentId + SiteId *int64 `json:"siteId"` + // The status of Media Import prior to status change action + StatusFrom *string `json:"statusFrom"` + // The status of Media Import after the status change action + StatusTo *string `json:"statusTo"` +} + +// GetImportId returns AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange.ImportId, and is useful for accessing the field via an interface. +func (v *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange) GetImportId() *int64 { + return v.ImportId +} + +// GetSiteId returns AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange.SiteId, and is useful for accessing the field via an interface. +func (v *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange) GetSiteId() *int64 { + return v.SiteId +} + +// GetStatusFrom returns AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange.StatusFrom, and is useful for accessing the field via an interface. +func (v *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange) GetStatusFrom() *string { + return v.StatusFrom +} + +// GetStatusTo returns AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange.StatusTo, and is useful for accessing the field via an interface. +func (v *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayloadMediaImportStatusChangeAppEnvironmentMediaImportStatusChange) GetStatusTo() *string { + return v.StatusTo +} + +// AbortMediaImportResponse is returned by AbortMediaImport on success. +type AbortMediaImportResponse struct { + // Abort a media import. + AbortMediaImport *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload `json:"abortMediaImport"` +} + +// GetAbortMediaImport returns AbortMediaImportResponse.AbortMediaImport, and is useful for accessing the field via an interface. +func (v *AbortMediaImportResponse) GetAbortMediaImport() *AbortMediaImportAbortMediaImportAppEnvironmentAbortMediaImportPayload { + return v.AbortMediaImport +} + +// AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayload includes the requested fields of the GraphQL type EnvironmentVariablesPayload. +// The GraphQL type's documentation follows. +// +// The updated environment variable list after a mutation. +type AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayload struct { + // The environment variables currently configured on the environment. + EnvironmentVariables *AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList `json:"environmentVariables"` +} + +// GetEnvironmentVariables returns AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayload.EnvironmentVariables, and is useful for accessing the field via an interface. +func (v *AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayload) GetEnvironmentVariables() *AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList { + return v.EnvironmentVariables +} + +// AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList includes the requested fields of the GraphQL type EnvironmentVariablesList. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variables / constants +type AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList struct { + // The total number of environment variables for this environment + Total *int64 `json:"total"` + // The environment variables for this environment + Nodes []*AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable `json:"nodes"` +} + +// GetTotal returns AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList.Total, and is useful for accessing the field via an interface. +func (v *AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList) GetTotal() *int64 { + return v.Total +} + +// GetNodes returns AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList.Nodes, and is useful for accessing the field via an interface. +func (v *AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList) GetNodes() []*AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable { + return v.Nodes +} + +// AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable includes the requested fields of the GraphQL type EnvironmentVariable. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variable / constant +type AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable struct { + // Environment variable name + Name string `json:"name"` +} + +// GetName returns AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable.Name, and is useful for accessing the field via an interface. +func (v *AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable) GetName() string { + return v.Name +} + +// AddEnvironmentVariableResponse is returned by AddEnvironmentVariable on success. +type AddEnvironmentVariableResponse struct { + // Add an environment variable to an application environment. + AddEnvironmentVariable *AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayload `json:"addEnvironmentVariable"` +} + +// GetAddEnvironmentVariable returns AddEnvironmentVariableResponse.AddEnvironmentVariable, and is useful for accessing the field via an interface. +func (v *AddEnvironmentVariableResponse) GetAddEnvironmentVariable() *AddEnvironmentVariableAddEnvironmentVariableEnvironmentVariablesPayload { + return v.AddEnvironmentVariable +} + +// AppBackupAndJobStatusApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type AppBackupAndJobStatusApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*AppBackupAndJobStatusAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns AppBackupAndJobStatusApp.Id, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusApp) GetId() *int64 { return v.Id } + +// GetEnvironments returns AppBackupAndJobStatusApp.Environments, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusApp) GetEnvironments() []*AppBackupAndJobStatusAppEnvironmentsAppEnvironment { + return v.Environments +} + +// AppBackupAndJobStatusAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type AppBackupAndJobStatusAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The SQL dump tool used for backups. + BackupsSqlDumpTool *string `json:"backupsSqlDumpTool"` + // The most recent backup for the environment. + LatestBackup *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup `json:"latestBackup"` + // Jobs running on or related to the environment. + Jobs []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface `json:"-"` +} + +// GetId returns AppBackupAndJobStatusAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetBackupsSqlDumpTool returns AppBackupAndJobStatusAppEnvironmentsAppEnvironment.BackupsSqlDumpTool, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironment) GetBackupsSqlDumpTool() *string { + return v.BackupsSqlDumpTool +} + +// GetLatestBackup returns AppBackupAndJobStatusAppEnvironmentsAppEnvironment.LatestBackup, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironment) GetLatestBackup() *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup { + return v.LatestBackup +} + +// GetJobs returns AppBackupAndJobStatusAppEnvironmentsAppEnvironment.Jobs, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironment) GetJobs() []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface { + return v.Jobs +} + +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironment) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *AppBackupAndJobStatusAppEnvironmentsAppEnvironment + Jobs []json.RawMessage `json:"jobs"` + graphql.NoUnmarshalJSON + } + firstPass.AppBackupAndJobStatusAppEnvironmentsAppEnvironment = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + { + dst := &v.Jobs + src := firstPass.Jobs + *dst = make( + []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface, + len(src)) + for i, src := range src { + dst := &(*dst)[i] + if len(src) != 0 && string(src) != "null" { + *dst = new(AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface) + err = __unmarshalAppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface( + src, *dst) + if err != nil { + return fmt.Errorf( + "unable to unmarshal AppBackupAndJobStatusAppEnvironmentsAppEnvironment.Jobs: %w", err) + } + } + } + } + return nil +} + +type __premarshalAppBackupAndJobStatusAppEnvironmentsAppEnvironment struct { + Id *int64 `json:"id"` + + BackupsSqlDumpTool *string `json:"backupsSqlDumpTool"` + + LatestBackup *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup `json:"latestBackup"` + + Jobs []json.RawMessage `json:"jobs"` +} + +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironment) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironment) __premarshalJSON() (*__premarshalAppBackupAndJobStatusAppEnvironmentsAppEnvironment, error) { + var retval __premarshalAppBackupAndJobStatusAppEnvironmentsAppEnvironment + + retval.Id = v.Id + retval.BackupsSqlDumpTool = v.BackupsSqlDumpTool + retval.LatestBackup = v.LatestBackup + { + + dst := &retval.Jobs + src := v.Jobs + *dst = make( + []json.RawMessage, + len(src)) + for i, src := range src { + dst := &(*dst)[i] + if src != nil { + var err error + *dst, err = __marshalAppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface( + src) + if err != nil { + return nil, fmt.Errorf( + "unable to marshal AppBackupAndJobStatusAppEnvironmentsAppEnvironment.Jobs: %w", err) + } + } + } + } + return &retval, nil +} + +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob includes the requested fields of the GraphQL type Job. +// The GraphQL type's documentation follows. +// +// A background job. +type AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob struct { + Typename *string `json:"__typename"` + // The unique identifier for the job. + Id *int64 `json:"id"` + // The job type. + Type *string `json:"type"` + // When the job completed. + CompletedAt *string `json:"completedAt"` + // When the job was created. + CreatedAt *string `json:"createdAt"` + // Whether the job currently holds an in-progress lock. + InProgressLock *bool `json:"inProgressLock"` + // Additional metadata for the job. + Metadata []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata `json:"metadata"` + // The current progress of the job. + Progress *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress `json:"progress"` +} + +// GetTypename returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob.Typename, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetTypename() *string { + return v.Typename +} + +// GetId returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob.Id, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetId() *int64 { return v.Id } + +// GetType returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob.Type, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetType() *string { return v.Type } + +// GetCompletedAt returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob.CompletedAt, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetCompletedAt() *string { + return v.CompletedAt +} + +// GetCreatedAt returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob.CreatedAt, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetCreatedAt() *string { + return v.CreatedAt +} + +// GetInProgressLock returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob.InProgressLock, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetInProgressLock() *bool { + return v.InProgressLock +} + +// GetMetadata returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob.Metadata, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetMetadata() []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata { + return v.Metadata +} + +// GetProgress returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob.Progress, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetProgress() *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress { + return v.Progress +} + +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface includes the requested fields of the GraphQL interface JobInterface. +// +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface is implemented by the following types: +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob +// The GraphQL type's documentation follows. +// +// Common fields shared by all job types. +type AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface interface { + implementsGraphQLInterfaceAppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface() + // GetTypename returns the receiver's concrete GraphQL type-name (see interface doc for possible values). + GetTypename() *string + // GetId returns the interface-field "id" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The unique identifier for the job. + GetId() *int64 + // GetType returns the interface-field "type" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The job type. + GetType() *string + // GetCompletedAt returns the interface-field "completedAt" from its implementation. + // The GraphQL interface field's documentation follows. + // + // When the job completed. + GetCompletedAt() *string + // GetCreatedAt returns the interface-field "createdAt" from its implementation. + // The GraphQL interface field's documentation follows. + // + // When the job was created. + GetCreatedAt() *string + // GetInProgressLock returns the interface-field "inProgressLock" from its implementation. + // The GraphQL interface field's documentation follows. + // + // Whether the job currently holds an in-progress lock. + GetInProgressLock() *bool + // GetMetadata returns the interface-field "metadata" from its implementation. + // The GraphQL interface field's documentation follows. + // + // Additional metadata for the job. + GetMetadata() []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata + // GetProgress returns the interface-field "progress" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The current progress of the job. + GetProgress() *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress +} + +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) implementsGraphQLInterfaceAppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface() { +} +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) implementsGraphQLInterfaceAppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface() { +} + +func __unmarshalAppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface(b []byte, v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface) error { + if string(b) == "null" { + return nil + } + + var tn struct { + TypeName string `json:"__typename"` + } + err := json.Unmarshal(b, &tn) + if err != nil { + return err + } + + switch tn.TypeName { + case "Job": + *v = new(AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob) + return json.Unmarshal(b, *v) + case "PrimaryDomainSwitchJob": + *v = new(AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) + return json.Unmarshal(b, *v) + case "": + return fmt.Errorf( + "response was missing JobInterface.__typename") + default: + return fmt.Errorf( + `unexpected concrete type for AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface: "%v"`, tn.TypeName) + } +} + +func __marshalAppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface(v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface) ([]byte, error) { + + var typename string + switch v := (*v).(type) { + case *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob: + typename = "Job" + + result := struct { + TypeName string `json:"__typename"` + *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJob + }{typename, v} + return json.Marshal(result) + case *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob: + typename = "PrimaryDomainSwitchJob" + + result := struct { + TypeName string `json:"__typename"` + *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob + }{typename, v} + return json.Marshal(result) + case nil: + return []byte("null"), nil + default: + return nil, fmt.Errorf( + `unexpected concrete type for AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface: "%T"`, v) + } +} + +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata includes the requested fields of the GraphQL type JobMetadata. +// The GraphQL type's documentation follows. +// +// A metadata entry attached to a job. +type AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata struct { + // The metadata key. + Name *string `json:"name"` + // The metadata value. + Value *string `json:"value"` +} + +// GetName returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata.Name, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata) GetName() *string { + return v.Name +} + +// GetValue returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata.Value, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata) GetValue() *string { + return v.Value +} + +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress includes the requested fields of the GraphQL type JobProgress. +// The GraphQL type's documentation follows. +// +// Progress details for a job. +type AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress struct { + // The current status of the job. + Status *string `json:"status"` + // The individual progress steps for the job. + Steps []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep `json:"steps"` +} + +// GetStatus returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress.Status, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress) GetStatus() *string { + return v.Status +} + +// GetSteps returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress.Steps, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress) GetSteps() []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep { + return v.Steps +} + +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep includes the requested fields of the GraphQL type JobProgressStep. +// The GraphQL type's documentation follows. +// +// A single progress step within a job. +type AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep struct { + // The unique identifier for the step. + Id *string `json:"id"` + // The display name of the step. + Name *string `json:"name"` + // The step key. + Step *string `json:"step"` + // The current status of the step. + Status *string `json:"status"` +} + +// GetId returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Id, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetId() *string { + return v.Id +} + +// GetName returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Name, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetName() *string { + return v.Name +} + +// GetStep returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Step, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetStep() *string { + return v.Step +} + +// GetStatus returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Status, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetStatus() *string { + return v.Status +} + +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob includes the requested fields of the GraphQL type PrimaryDomainSwitchJob. +// The GraphQL type's documentation follows. +// +// A job that switches an environment's primary domain. +type AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob struct { + Typename *string `json:"__typename"` + // The unique identifier for the job. + Id *int64 `json:"id"` + // The job type. + Type *string `json:"type"` + // When the job completed. + CompletedAt *string `json:"completedAt"` + // When the job was created. + CreatedAt *string `json:"createdAt"` + // Whether the job currently holds an in-progress lock. + InProgressLock *bool `json:"inProgressLock"` + // Additional metadata for the job. + Metadata []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata `json:"metadata"` + // The current progress of the job. + Progress *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress `json:"progress"` +} + +// GetTypename returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Typename, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetTypename() *string { + return v.Typename +} + +// GetId returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Id, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetId() *int64 { + return v.Id +} + +// GetType returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Type, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetType() *string { + return v.Type +} + +// GetCompletedAt returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.CompletedAt, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetCompletedAt() *string { + return v.CompletedAt +} + +// GetCreatedAt returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.CreatedAt, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetCreatedAt() *string { + return v.CreatedAt +} + +// GetInProgressLock returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.InProgressLock, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetInProgressLock() *bool { + return v.InProgressLock +} + +// GetMetadata returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Metadata, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetMetadata() []*AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata { + return v.Metadata +} + +// GetProgress returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Progress, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetProgress() *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress { + return v.Progress +} + +// AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup includes the requested fields of the GraphQL type Backup. +// The GraphQL type's documentation follows. +// +// A backup available for an environment. +type AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup struct { + // The unique identifier for the backup. + Id *float64 `json:"id"` + // The backup type. + Type *string `json:"type"` + // The backup size in bytes. + Size *float64 `json:"size"` + // The backup filename. + Filename *string `json:"filename"` + // The SQL dump tool used to generate the backup. + SqlDumpTool *string `json:"sqlDumpTool"` + // When the backup was created. + CreatedAt *string `json:"createdAt"` +} + +// GetId returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup.Id, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup) GetId() *float64 { + return v.Id +} + +// GetType returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup.Type, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup) GetType() *string { + return v.Type +} + +// GetSize returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup.Size, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup) GetSize() *float64 { + return v.Size +} + +// GetFilename returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup.Filename, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup) GetFilename() *string { + return v.Filename +} + +// GetSqlDumpTool returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup.SqlDumpTool, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup) GetSqlDumpTool() *string { + return v.SqlDumpTool +} + +// GetCreatedAt returns AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup.CreatedAt, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusAppEnvironmentsAppEnvironmentLatestBackup) GetCreatedAt() *string { + return v.CreatedAt +} + +// AppBackupAndJobStatusResponse is returned by AppBackupAndJobStatus on success. +type AppBackupAndJobStatusResponse struct { + // Retrieve a single application. + App *AppBackupAndJobStatusApp `json:"app"` +} + +// GetApp returns AppBackupAndJobStatusResponse.App, and is useful for accessing the field via an interface. +func (v *AppBackupAndJobStatusResponse) GetApp() *AppBackupAndJobStatusApp { return v.App } + +// AppBackupJobStatusApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type AppBackupJobStatusApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*AppBackupJobStatusAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns AppBackupJobStatusApp.Id, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusApp) GetId() *int64 { return v.Id } + +// GetEnvironments returns AppBackupJobStatusApp.Environments, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusApp) GetEnvironments() []*AppBackupJobStatusAppEnvironmentsAppEnvironment { + return v.Environments +} + +// AppBackupJobStatusAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type AppBackupJobStatusAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // Jobs running on or related to the environment. + Jobs []*AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface `json:"-"` +} + +// GetId returns AppBackupJobStatusAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetJobs returns AppBackupJobStatusAppEnvironmentsAppEnvironment.Jobs, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironment) GetJobs() []*AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface { + return v.Jobs +} + +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironment) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *AppBackupJobStatusAppEnvironmentsAppEnvironment + Jobs []json.RawMessage `json:"jobs"` + graphql.NoUnmarshalJSON + } + firstPass.AppBackupJobStatusAppEnvironmentsAppEnvironment = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + { + dst := &v.Jobs + src := firstPass.Jobs + *dst = make( + []*AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface, + len(src)) + for i, src := range src { + dst := &(*dst)[i] + if len(src) != 0 && string(src) != "null" { + *dst = new(AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface) + err = __unmarshalAppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface( + src, *dst) + if err != nil { + return fmt.Errorf( + "unable to unmarshal AppBackupJobStatusAppEnvironmentsAppEnvironment.Jobs: %w", err) + } + } + } + } + return nil +} + +type __premarshalAppBackupJobStatusAppEnvironmentsAppEnvironment struct { + Id *int64 `json:"id"` + + Jobs []json.RawMessage `json:"jobs"` +} + +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironment) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironment) __premarshalJSON() (*__premarshalAppBackupJobStatusAppEnvironmentsAppEnvironment, error) { + var retval __premarshalAppBackupJobStatusAppEnvironmentsAppEnvironment + + retval.Id = v.Id + { + + dst := &retval.Jobs + src := v.Jobs + *dst = make( + []json.RawMessage, + len(src)) + for i, src := range src { + dst := &(*dst)[i] + if src != nil { + var err error + *dst, err = __marshalAppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface( + src) + if err != nil { + return nil, fmt.Errorf( + "unable to marshal AppBackupJobStatusAppEnvironmentsAppEnvironment.Jobs: %w", err) + } + } + } + } + return &retval, nil +} + +// AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob includes the requested fields of the GraphQL type Job. +// The GraphQL type's documentation follows. +// +// A background job. +type AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob struct { + Typename *string `json:"__typename"` + // The unique identifier for the job. + Id *int64 `json:"id"` + // The job type. + Type *string `json:"type"` + // When the job completed. + CompletedAt *string `json:"completedAt"` + // When the job was created. + CreatedAt *string `json:"createdAt"` + // Whether the job currently holds an in-progress lock. + InProgressLock *bool `json:"inProgressLock"` + // Additional metadata for the job. + Metadata []*AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata `json:"metadata"` + // The current progress of the job. + Progress *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress `json:"progress"` +} + +// GetTypename returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob.Typename, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetTypename() *string { + return v.Typename +} + +// GetId returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob.Id, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetId() *int64 { return v.Id } + +// GetType returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob.Type, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetType() *string { return v.Type } + +// GetCompletedAt returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob.CompletedAt, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetCompletedAt() *string { + return v.CompletedAt +} + +// GetCreatedAt returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob.CreatedAt, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetCreatedAt() *string { + return v.CreatedAt +} + +// GetInProgressLock returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob.InProgressLock, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetInProgressLock() *bool { + return v.InProgressLock +} + +// GetMetadata returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob.Metadata, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetMetadata() []*AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata { + return v.Metadata +} + +// GetProgress returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob.Progress, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) GetProgress() *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress { + return v.Progress +} + +// AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface includes the requested fields of the GraphQL interface JobInterface. +// +// AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface is implemented by the following types: +// AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob +// AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob +// The GraphQL type's documentation follows. +// +// Common fields shared by all job types. +type AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface interface { + implementsGraphQLInterfaceAppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface() + // GetTypename returns the receiver's concrete GraphQL type-name (see interface doc for possible values). + GetTypename() *string + // GetId returns the interface-field "id" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The unique identifier for the job. + GetId() *int64 + // GetType returns the interface-field "type" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The job type. + GetType() *string + // GetCompletedAt returns the interface-field "completedAt" from its implementation. + // The GraphQL interface field's documentation follows. + // + // When the job completed. + GetCompletedAt() *string + // GetCreatedAt returns the interface-field "createdAt" from its implementation. + // The GraphQL interface field's documentation follows. + // + // When the job was created. + GetCreatedAt() *string + // GetInProgressLock returns the interface-field "inProgressLock" from its implementation. + // The GraphQL interface field's documentation follows. + // + // Whether the job currently holds an in-progress lock. + GetInProgressLock() *bool + // GetMetadata returns the interface-field "metadata" from its implementation. + // The GraphQL interface field's documentation follows. + // + // Additional metadata for the job. + GetMetadata() []*AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata + // GetProgress returns the interface-field "progress" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The current progress of the job. + GetProgress() *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress +} + +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) implementsGraphQLInterfaceAppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface() { +} +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) implementsGraphQLInterfaceAppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface() { +} + +func __unmarshalAppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface(b []byte, v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface) error { + if string(b) == "null" { + return nil + } + + var tn struct { + TypeName string `json:"__typename"` + } + err := json.Unmarshal(b, &tn) + if err != nil { + return err + } + + switch tn.TypeName { + case "Job": + *v = new(AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob) + return json.Unmarshal(b, *v) + case "PrimaryDomainSwitchJob": + *v = new(AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) + return json.Unmarshal(b, *v) + case "": + return fmt.Errorf( + "response was missing JobInterface.__typename") + default: + return fmt.Errorf( + `unexpected concrete type for AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface: "%v"`, tn.TypeName) + } +} + +func __marshalAppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface(v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface) ([]byte, error) { + + var typename string + switch v := (*v).(type) { + case *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob: + typename = "Job" + + result := struct { + TypeName string `json:"__typename"` + *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJob + }{typename, v} + return json.Marshal(result) + case *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob: + typename = "PrimaryDomainSwitchJob" + + result := struct { + TypeName string `json:"__typename"` + *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob + }{typename, v} + return json.Marshal(result) + case nil: + return []byte("null"), nil + default: + return nil, fmt.Errorf( + `unexpected concrete type for AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterface: "%T"`, v) + } +} + +// AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata includes the requested fields of the GraphQL type JobMetadata. +// The GraphQL type's documentation follows. +// +// A metadata entry attached to a job. +type AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata struct { + // The metadata key. + Name *string `json:"name"` + // The metadata value. + Value *string `json:"value"` +} + +// GetName returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata.Name, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata) GetName() *string { + return v.Name +} + +// GetValue returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata.Value, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata) GetValue() *string { + return v.Value +} + +// AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress includes the requested fields of the GraphQL type JobProgress. +// The GraphQL type's documentation follows. +// +// Progress details for a job. +type AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress struct { + // The current status of the job. + Status *string `json:"status"` +} + +// GetStatus returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress.Status, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress) GetStatus() *string { + return v.Status +} + +// AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob includes the requested fields of the GraphQL type PrimaryDomainSwitchJob. +// The GraphQL type's documentation follows. +// +// A job that switches an environment's primary domain. +type AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob struct { + Typename *string `json:"__typename"` + // The unique identifier for the job. + Id *int64 `json:"id"` + // The job type. + Type *string `json:"type"` + // When the job completed. + CompletedAt *string `json:"completedAt"` + // When the job was created. + CreatedAt *string `json:"createdAt"` + // Whether the job currently holds an in-progress lock. + InProgressLock *bool `json:"inProgressLock"` + // Additional metadata for the job. + Metadata []*AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata `json:"metadata"` + // The current progress of the job. + Progress *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress `json:"progress"` +} + +// GetTypename returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Typename, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetTypename() *string { + return v.Typename +} + +// GetId returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Id, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetId() *int64 { + return v.Id +} + +// GetType returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Type, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetType() *string { + return v.Type +} + +// GetCompletedAt returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.CompletedAt, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetCompletedAt() *string { + return v.CompletedAt +} + +// GetCreatedAt returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.CreatedAt, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetCreatedAt() *string { + return v.CreatedAt +} + +// GetInProgressLock returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.InProgressLock, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetInProgressLock() *bool { + return v.InProgressLock +} + +// GetMetadata returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Metadata, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetMetadata() []*AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceMetadataJobMetadata { + return v.Metadata +} + +// GetProgress returns AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Progress, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetProgress() *AppBackupJobStatusAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress { + return v.Progress +} + +// AppBackupJobStatusResponse is returned by AppBackupJobStatus on success. +type AppBackupJobStatusResponse struct { + // Retrieve a single application. + App *AppBackupJobStatusApp `json:"app"` +} + +// GetApp returns AppBackupJobStatusResponse.App, and is useful for accessing the field via an interface. +func (v *AppBackupJobStatusResponse) GetApp() *AppBackupJobStatusApp { return v.App } + +// AppBasic includes the GraphQL fields of App requested by the fragment AppBasic. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type AppBasic struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The source repository for the application in `owner/name` format. + Repo *string `json:"repo"` +} + +// GetId returns AppBasic.Id, and is useful for accessing the field via an interface. +func (v *AppBasic) GetId() *int64 { return v.Id } + +// GetName returns AppBasic.Name, and is useful for accessing the field via an interface. +func (v *AppBasic) GetName() *string { return v.Name } + +// GetRepo returns AppBasic.Repo, and is useful for accessing the field via an interface. +func (v *AppBasic) GetRepo() *string { return v.Repo } + +// Mutation request input to abort a Media Import +type AppEnvironmentAbortMediaImportInput struct { + // The unique ID of the Application + ApplicationId int64 `json:"applicationId"` + // The uniqueID of the Environment + EnvironmentId int64 `json:"environmentId"` +} + +// GetApplicationId returns AppEnvironmentAbortMediaImportInput.ApplicationId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentAbortMediaImportInput) GetApplicationId() int64 { return v.ApplicationId } + +// GetEnvironmentId returns AppEnvironmentAbortMediaImportInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentAbortMediaImportInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// Input for starting a custom deploy. +type AppEnvironmentCustomDeployInput struct { + // The application ID, when required by the caller. + Id *int64 `json:"id"` + // The environment ID to deploy to. + EnvironmentId *int64 `json:"environmentId"` + // The deployment artifact filename. + Basename *string `json:"basename"` + // The checksum of the deployment artifact. + Checksum *string `json:"checksum"` + // The deploy message to record. + DeployMessage *string `json:"deployMessage"` +} + +// GetId returns AppEnvironmentCustomDeployInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentCustomDeployInput) GetId() *int64 { return v.Id } + +// GetEnvironmentId returns AppEnvironmentCustomDeployInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentCustomDeployInput) GetEnvironmentId() *int64 { return v.EnvironmentId } + +// GetBasename returns AppEnvironmentCustomDeployInput.Basename, and is useful for accessing the field via an interface. +func (v *AppEnvironmentCustomDeployInput) GetBasename() *string { return v.Basename } + +// GetChecksum returns AppEnvironmentCustomDeployInput.Checksum, and is useful for accessing the field via an interface. +func (v *AppEnvironmentCustomDeployInput) GetChecksum() *string { return v.Checksum } + +// GetDeployMessage returns AppEnvironmentCustomDeployInput.DeployMessage, and is useful for accessing the field via an interface. +func (v *AppEnvironmentCustomDeployInput) GetDeployMessage() *string { return v.DeployMessage } + +// Input for updating defensive mode configuration. +type AppEnvironmentDefensiveModeConfigInput struct { + // The challenge type to apply. + ChallengeType int64 `json:"challengeType"` + // The absolute connection threshold that triggers defensive mode. + ConnectionThresholdAbsolute *int64 `json:"connectionThresholdAbsolute"` + // The connection threshold percentage that triggers defensive mode. + ConnectionThresholdPercentage *int64 `json:"connectionThresholdPercentage"` + // Whether defensive mode should be enabled. + Enabled bool `json:"enabled"` + // The environment ID. + EnvironmentId int64 `json:"environmentId"` + // The application ID. + Id int64 `json:"id"` +} + +// GetChallengeType returns AppEnvironmentDefensiveModeConfigInput.ChallengeType, and is useful for accessing the field via an interface. +func (v *AppEnvironmentDefensiveModeConfigInput) GetChallengeType() int64 { return v.ChallengeType } + +// GetConnectionThresholdAbsolute returns AppEnvironmentDefensiveModeConfigInput.ConnectionThresholdAbsolute, and is useful for accessing the field via an interface. +func (v *AppEnvironmentDefensiveModeConfigInput) GetConnectionThresholdAbsolute() *int64 { + return v.ConnectionThresholdAbsolute +} + +// GetConnectionThresholdPercentage returns AppEnvironmentDefensiveModeConfigInput.ConnectionThresholdPercentage, and is useful for accessing the field via an interface. +func (v *AppEnvironmentDefensiveModeConfigInput) GetConnectionThresholdPercentage() *int64 { + return v.ConnectionThresholdPercentage +} + +// GetEnabled returns AppEnvironmentDefensiveModeConfigInput.Enabled, and is useful for accessing the field via an interface. +func (v *AppEnvironmentDefensiveModeConfigInput) GetEnabled() bool { return v.Enabled } + +// GetEnvironmentId returns AppEnvironmentDefensiveModeConfigInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentDefensiveModeConfigInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GetId returns AppEnvironmentDefensiveModeConfigInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentDefensiveModeConfigInput) GetId() int64 { return v.Id } + +// Input for enabling or disabling defensive mode. +type AppEnvironmentDefensiveModeUpdateStatusInput struct { + // Whether defensive mode should be enabled. + Enabled bool `json:"enabled"` + // The environment ID. + EnvironmentId int64 `json:"environmentId"` + // The application ID. + Id int64 `json:"id"` +} + +// GetEnabled returns AppEnvironmentDefensiveModeUpdateStatusInput.Enabled, and is useful for accessing the field via an interface. +func (v *AppEnvironmentDefensiveModeUpdateStatusInput) GetEnabled() bool { return v.Enabled } + +// GetEnvironmentId returns AppEnvironmentDefensiveModeUpdateStatusInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentDefensiveModeUpdateStatusInput) GetEnvironmentId() int64 { + return v.EnvironmentId +} + +// GetId returns AppEnvironmentDefensiveModeUpdateStatusInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentDefensiveModeUpdateStatusInput) GetId() int64 { return v.Id } + +// Input for generating a database backup copy download URL. +type AppEnvironmentGenerateDBBackupCopyUrlInput struct { + // The backup ID to generate a URL for. + BackupId *float64 `json:"backupId"` + // The environment ID. + EnvironmentId *int64 `json:"environmentId"` + // The application ID. + Id *int64 `json:"id"` +} + +// GetBackupId returns AppEnvironmentGenerateDBBackupCopyUrlInput.BackupId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentGenerateDBBackupCopyUrlInput) GetBackupId() *float64 { return v.BackupId } + +// GetEnvironmentId returns AppEnvironmentGenerateDBBackupCopyUrlInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentGenerateDBBackupCopyUrlInput) GetEnvironmentId() *int64 { + return v.EnvironmentId +} + +// GetId returns AppEnvironmentGenerateDBBackupCopyUrlInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentGenerateDBBackupCopyUrlInput) GetId() *int64 { return v.Id } + +// Input for starting an environment import. +type AppEnvironmentImportInput struct { + // The backup basename to import. + Basename *string `json:"basename"` + // The environment ID. + EnvironmentId *int64 `json:"environmentId"` + // The application ID. + Id *int64 `json:"id"` + // The expected MD5 checksum. + Md5 *string `json:"md5"` + // The search-and-replace rules to apply. + SearchReplace []*AppEnvironmentImportSearchReplace `json:"searchReplace"` + // Whether to skip creating a backup before import. + SkipBackup *bool `json:"skipBackup"` + // Whether to skip maintenance mode during import. + SkipMaintenanceMode *bool `json:"skipMaintenanceMode"` + // The source URL to import from. + Url *string `json:"url"` + // The request headers to include when fetching the source URL. + UrlHeaders []*RequestHeader `json:"urlHeaders"` +} + +// GetBasename returns AppEnvironmentImportInput.Basename, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportInput) GetBasename() *string { return v.Basename } + +// GetEnvironmentId returns AppEnvironmentImportInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportInput) GetEnvironmentId() *int64 { return v.EnvironmentId } + +// GetId returns AppEnvironmentImportInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportInput) GetId() *int64 { return v.Id } + +// GetMd5 returns AppEnvironmentImportInput.Md5, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportInput) GetMd5() *string { return v.Md5 } + +// GetSearchReplace returns AppEnvironmentImportInput.SearchReplace, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportInput) GetSearchReplace() []*AppEnvironmentImportSearchReplace { + return v.SearchReplace +} + +// GetSkipBackup returns AppEnvironmentImportInput.SkipBackup, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportInput) GetSkipBackup() *bool { return v.SkipBackup } + +// GetSkipMaintenanceMode returns AppEnvironmentImportInput.SkipMaintenanceMode, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportInput) GetSkipMaintenanceMode() *bool { return v.SkipMaintenanceMode } + +// GetUrl returns AppEnvironmentImportInput.Url, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportInput) GetUrl() *string { return v.Url } + +// GetUrlHeaders returns AppEnvironmentImportInput.UrlHeaders, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportInput) GetUrlHeaders() []*RequestHeader { return v.UrlHeaders } + +// A search-and-replace rule applied during import. +type AppEnvironmentImportSearchReplace struct { + // The source string to replace. + From *string `json:"from"` + // The replacement string. + To *string `json:"to,omitempty"` +} + +// GetFrom returns AppEnvironmentImportSearchReplace.From, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportSearchReplace) GetFrom() *string { return v.From } + +// GetTo returns AppEnvironmentImportSearchReplace.To, and is useful for accessing the field via an interface. +func (v *AppEnvironmentImportSearchReplace) GetTo() *string { return v.To } + +// Input for generating a live backup copy download URL. +type AppEnvironmentLiveBackupCopyDownloadURLInput struct { + // The live backup copy ID. + CopyId string `json:"copyId"` + // The environment ID. + EnvironmentId int64 `json:"environmentId"` + // The application ID. + Id int64 `json:"id"` +} + +// GetCopyId returns AppEnvironmentLiveBackupCopyDownloadURLInput.CopyId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentLiveBackupCopyDownloadURLInput) GetCopyId() string { return v.CopyId } + +// GetEnvironmentId returns AppEnvironmentLiveBackupCopyDownloadURLInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentLiveBackupCopyDownloadURLInput) GetEnvironmentId() int64 { + return v.EnvironmentId +} + +// GetId returns AppEnvironmentLiveBackupCopyDownloadURLInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentLiveBackupCopyDownloadURLInput) GetId() int64 { return v.Id } + +// The available environment log streams. +type AppEnvironmentLogType string + +const ( + // Application logs (`type: app`). + AppEnvironmentLogTypeApp AppEnvironmentLogType = "app" + // Batch job logs (`type: batch`). + AppEnvironmentLogTypeBatch AppEnvironmentLogType = "batch" +) + +var AllAppEnvironmentLogType = []AppEnvironmentLogType{ + AppEnvironmentLogTypeApp, + AppEnvironmentLogTypeBatch, +} + +// Input for starting a database backup copy. +type AppEnvironmentStartDBBackupCopyInput struct { + // The backup ID to copy. + BackupId *float64 `json:"backupId"` + // The environment ID. + EnvironmentId *int64 `json:"environmentId"` + // The application ID. + Id *int64 `json:"id"` + // The subsite ID to target, when applicable. + SubsiteId *int64 `json:"subsiteId"` + // The tables to include in the copy. + Tables []*string `json:"tables"` +} + +// GetBackupId returns AppEnvironmentStartDBBackupCopyInput.BackupId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartDBBackupCopyInput) GetBackupId() *float64 { return v.BackupId } + +// GetEnvironmentId returns AppEnvironmentStartDBBackupCopyInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartDBBackupCopyInput) GetEnvironmentId() *int64 { return v.EnvironmentId } + +// GetId returns AppEnvironmentStartDBBackupCopyInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartDBBackupCopyInput) GetId() *int64 { return v.Id } + +// GetSubsiteId returns AppEnvironmentStartDBBackupCopyInput.SubsiteId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartDBBackupCopyInput) GetSubsiteId() *int64 { return v.SubsiteId } + +// GetTables returns AppEnvironmentStartDBBackupCopyInput.Tables, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartDBBackupCopyInput) GetTables() []*string { return v.Tables } + +// Mutation request input to start a Media Import +type AppEnvironmentStartMediaImportInput struct { + // API version to be used for the media import + ApiVersion *string `json:"apiVersion"` + // The unique ID of the Application + ApplicationId int64 `json:"applicationId"` + // Publicly accessible URL that contains an archive of the media files to be imported + ArchiveUrl string `json:"archiveUrl"` + // The uniqueID of the Environment + EnvironmentId int64 `json:"environmentId"` + // Whether to import intermediate images or not + ImportIntermediateImages *bool `json:"importIntermediateImages"` + // Whether to overwrite existing files or not + OverwriteExistingFiles *bool `json:"overwriteExistingFiles"` +} + +// GetApiVersion returns AppEnvironmentStartMediaImportInput.ApiVersion, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartMediaImportInput) GetApiVersion() *string { return v.ApiVersion } + +// GetApplicationId returns AppEnvironmentStartMediaImportInput.ApplicationId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartMediaImportInput) GetApplicationId() int64 { return v.ApplicationId } + +// GetArchiveUrl returns AppEnvironmentStartMediaImportInput.ArchiveUrl, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartMediaImportInput) GetArchiveUrl() string { return v.ArchiveUrl } + +// GetEnvironmentId returns AppEnvironmentStartMediaImportInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartMediaImportInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GetImportIntermediateImages returns AppEnvironmentStartMediaImportInput.ImportIntermediateImages, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartMediaImportInput) GetImportIntermediateImages() *bool { + return v.ImportIntermediateImages +} + +// GetOverwriteExistingFiles returns AppEnvironmentStartMediaImportInput.OverwriteExistingFiles, and is useful for accessing the field via an interface. +func (v *AppEnvironmentStartMediaImportInput) GetOverwriteExistingFiles() *bool { + return v.OverwriteExistingFiles +} + +// Input for triggering an environment sync. +type AppEnvironmentSyncInput struct { + // The copy configuration payload. + Config *json.RawMessage `json:"config"` + // The environment ID to sync. + EnvironmentId int64 `json:"environmentId"` + // The source environment ID to sync from. + FromEnvironmentId *int64 `json:"fromEnvironmentId"` + // The application ID. + Id int64 `json:"id"` +} + +// GetConfig returns AppEnvironmentSyncInput.Config, and is useful for accessing the field via an interface. +func (v *AppEnvironmentSyncInput) GetConfig() *json.RawMessage { return v.Config } + +// GetEnvironmentId returns AppEnvironmentSyncInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentSyncInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GetFromEnvironmentId returns AppEnvironmentSyncInput.FromEnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentSyncInput) GetFromEnvironmentId() *int64 { return v.FromEnvironmentId } + +// GetId returns AppEnvironmentSyncInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentSyncInput) GetId() int64 { return v.Id } + +// Input for triggering a database backup. +type AppEnvironmentTriggerDBBackupInput struct { + // Whether to perform a dry run. + DryRun *bool `json:"dryRun"` + // The environment ID. + EnvironmentId int64 `json:"environmentId"` + // The application ID. + Id int64 `json:"id"` +} + +// GetDryRun returns AppEnvironmentTriggerDBBackupInput.DryRun, and is useful for accessing the field via an interface. +func (v *AppEnvironmentTriggerDBBackupInput) GetDryRun() *bool { return v.DryRun } + +// GetEnvironmentId returns AppEnvironmentTriggerDBBackupInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentTriggerDBBackupInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GetId returns AppEnvironmentTriggerDBBackupInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentTriggerDBBackupInput) GetId() int64 { return v.Id } + +// Variables for the Run WP-CLI Command mutation +type AppEnvironmentTriggerWPCLICommandInput struct { + // The command we want to run. Note: should not include 'wp' + Command *string `json:"command"` + // The environment ID where we want to run the command + EnvironmentId *int64 `json:"environmentId"` + // The application ID + Id *int64 `json:"id"` +} + +// GetCommand returns AppEnvironmentTriggerWPCLICommandInput.Command, and is useful for accessing the field via an interface. +func (v *AppEnvironmentTriggerWPCLICommandInput) GetCommand() *string { return v.Command } + +// GetEnvironmentId returns AppEnvironmentTriggerWPCLICommandInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *AppEnvironmentTriggerWPCLICommandInput) GetEnvironmentId() *int64 { return v.EnvironmentId } + +// GetId returns AppEnvironmentTriggerWPCLICommandInput.Id, and is useful for accessing the field via an interface. +func (v *AppEnvironmentTriggerWPCLICommandInput) GetId() *int64 { return v.Id } + +// The strategies available for running WP-CLI commands. +type AppEnvironmentWPCliStrategy string + +const ( + // Run WP-CLI over SSH. + AppEnvironmentWPCliStrategySsh AppEnvironmentWPCliStrategy = "ssh" + // Run WP-CLI over a websocket connection. + AppEnvironmentWPCliStrategyWebsocket AppEnvironmentWPCliStrategy = "websocket" +) + +var AllAppEnvironmentWPCliStrategy = []AppEnvironmentWPCliStrategy{ + AppEnvironmentWPCliStrategySsh, + AppEnvironmentWPCliStrategyWebsocket, +} + +// AppGetByIDApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type AppGetByIDApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The source repository for the application in `owner/name` format. + Repo *string `json:"repo"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*AppGetByIDAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns AppGetByIDApp.Id, and is useful for accessing the field via an interface. +func (v *AppGetByIDApp) GetId() *int64 { return v.Id } + +// GetName returns AppGetByIDApp.Name, and is useful for accessing the field via an interface. +func (v *AppGetByIDApp) GetName() *string { return v.Name } + +// GetRepo returns AppGetByIDApp.Repo, and is useful for accessing the field via an interface. +func (v *AppGetByIDApp) GetRepo() *string { return v.Repo } + +// GetEnvironments returns AppGetByIDApp.Environments, and is useful for accessing the field via an interface. +func (v *AppGetByIDApp) GetEnvironments() []*AppGetByIDAppEnvironmentsAppEnvironment { + return v.Environments +} + +// AppGetByIDAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type AppGetByIDAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The application ID that owns the environment. + AppId *int64 `json:"appId"` + // The display name of the environment. + Name *string `json:"name"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // The currently configured branch for the environment. + Branch *string `json:"branch"` + // The current deployed commit SHA. + CurrentCommit *string `json:"currentCommit"` + // The primary domain for the environment. + PrimaryDomain *AppGetByIDAppEnvironmentsAppEnvironmentPrimaryDomain `json:"primaryDomain"` + // Whether the environment has been launched. + Launched *bool `json:"launched"` + // The deployment strategy configured for the environment. + DeploymentStrategy *string `json:"deploymentStrategy"` +} + +// GetId returns AppGetByIDAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetAppId returns AppGetByIDAppEnvironmentsAppEnvironment.AppId, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironment) GetAppId() *int64 { return v.AppId } + +// GetName returns AppGetByIDAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironment) GetName() *string { return v.Name } + +// GetType returns AppGetByIDAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironment) GetType() *string { return v.Type } + +// GetBranch returns AppGetByIDAppEnvironmentsAppEnvironment.Branch, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironment) GetBranch() *string { return v.Branch } + +// GetCurrentCommit returns AppGetByIDAppEnvironmentsAppEnvironment.CurrentCommit, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironment) GetCurrentCommit() *string { return v.CurrentCommit } + +// GetPrimaryDomain returns AppGetByIDAppEnvironmentsAppEnvironment.PrimaryDomain, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironment) GetPrimaryDomain() *AppGetByIDAppEnvironmentsAppEnvironmentPrimaryDomain { + return v.PrimaryDomain +} + +// GetLaunched returns AppGetByIDAppEnvironmentsAppEnvironment.Launched, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironment) GetLaunched() *bool { return v.Launched } + +// GetDeploymentStrategy returns AppGetByIDAppEnvironmentsAppEnvironment.DeploymentStrategy, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironment) GetDeploymentStrategy() *string { + return v.DeploymentStrategy +} + +// AppGetByIDAppEnvironmentsAppEnvironmentPrimaryDomain includes the requested fields of the GraphQL type Domain. +// The GraphQL type's documentation follows. +// +// A domain for an environment +type AppGetByIDAppEnvironmentsAppEnvironmentPrimaryDomain struct { + // The domain name (i.e. something like example.com or sub.example.com) + Name string `json:"name"` +} + +// GetName returns AppGetByIDAppEnvironmentsAppEnvironmentPrimaryDomain.Name, and is useful for accessing the field via an interface. +func (v *AppGetByIDAppEnvironmentsAppEnvironmentPrimaryDomain) GetName() string { return v.Name } + +// AppGetByIDResponse is returned by AppGetByID on success. +type AppGetByIDResponse struct { + // Retrieve a single application. + App *AppGetByIDApp `json:"app"` +} + +// GetApp returns AppGetByIDResponse.App, and is useful for accessing the field via an interface. +func (v *AppGetByIDResponse) GetApp() *AppGetByIDApp { return v.App } + +// AppGetByNameAppsAppList includes the requested fields of the GraphQL type AppList. +// The GraphQL type's documentation follows. +// +// A paginated list of applications. +type AppGetByNameAppsAppList struct { + // A legacy alias for `nodes`. + Edges []*AppGetByNameAppsAppListEdgesApp `json:"edges"` +} + +// GetEdges returns AppGetByNameAppsAppList.Edges, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppList) GetEdges() []*AppGetByNameAppsAppListEdgesApp { return v.Edges } + +// AppGetByNameAppsAppListEdgesApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type AppGetByNameAppsAppListEdgesApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The source repository for the application in `owner/name` format. + Repo *string `json:"repo"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns AppGetByNameAppsAppListEdgesApp.Id, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesApp) GetId() *int64 { return v.Id } + +// GetName returns AppGetByNameAppsAppListEdgesApp.Name, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesApp) GetName() *string { return v.Name } + +// GetRepo returns AppGetByNameAppsAppListEdgesApp.Repo, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesApp) GetRepo() *string { return v.Repo } + +// GetEnvironments returns AppGetByNameAppsAppListEdgesApp.Environments, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesApp) GetEnvironments() []*AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment { + return v.Environments +} + +// AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The application ID that owns the environment. + AppId *int64 `json:"appId"` + // The display name of the environment. + Name *string `json:"name"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // The currently configured branch for the environment. + Branch *string `json:"branch"` + // The current deployed commit SHA. + CurrentCommit *string `json:"currentCommit"` + // The primary domain for the environment. + PrimaryDomain *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironmentPrimaryDomain `json:"primaryDomain"` + // Whether the environment has been launched. + Launched *bool `json:"launched"` + // The deployment strategy configured for the environment. + DeploymentStrategy *string `json:"deploymentStrategy"` +} + +// GetId returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetAppId returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.AppId, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetAppId() *int64 { return v.AppId } + +// GetName returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetName() *string { return v.Name } + +// GetType returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetType() *string { return v.Type } + +// GetBranch returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.Branch, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetBranch() *string { + return v.Branch +} + +// GetCurrentCommit returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.CurrentCommit, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetCurrentCommit() *string { + return v.CurrentCommit +} + +// GetPrimaryDomain returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.PrimaryDomain, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetPrimaryDomain() *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironmentPrimaryDomain { + return v.PrimaryDomain +} + +// GetLaunched returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.Launched, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetLaunched() *bool { + return v.Launched +} + +// GetDeploymentStrategy returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.DeploymentStrategy, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetDeploymentStrategy() *string { + return v.DeploymentStrategy +} + +// AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironmentPrimaryDomain includes the requested fields of the GraphQL type Domain. +// The GraphQL type's documentation follows. +// +// A domain for an environment +type AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironmentPrimaryDomain struct { + // The domain name (i.e. something like example.com or sub.example.com) + Name string `json:"name"` +} + +// GetName returns AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironmentPrimaryDomain.Name, and is useful for accessing the field via an interface. +func (v *AppGetByNameAppsAppListEdgesAppEnvironmentsAppEnvironmentPrimaryDomain) GetName() string { + return v.Name +} + +// AppGetByNameResponse is returned by AppGetByName on success. +type AppGetByNameResponse struct { + // Retrieve a paginated list of applications. + Apps *AppGetByNameAppsAppList `json:"apps"` +} + +// GetApps returns AppGetByNameResponse.Apps, and is useful for accessing the field via an interface. +func (v *AppGetByNameResponse) GetApps() *AppGetByNameAppsAppList { return v.Apps } + +// AppListAppsAppList includes the requested fields of the GraphQL type AppList. +// The GraphQL type's documentation follows. +// +// A paginated list of applications. +type AppListAppsAppList struct { + // The total number of matching applications. + Total *int64 `json:"total"` + // The cursor for the next page of applications. + NextCursor *string `json:"nextCursor"` + // A legacy alias for `nodes`. + Edges []*AppListAppsAppListEdgesApp `json:"edges"` +} + +// GetTotal returns AppListAppsAppList.Total, and is useful for accessing the field via an interface. +func (v *AppListAppsAppList) GetTotal() *int64 { return v.Total } + +// GetNextCursor returns AppListAppsAppList.NextCursor, and is useful for accessing the field via an interface. +func (v *AppListAppsAppList) GetNextCursor() *string { return v.NextCursor } + +// GetEdges returns AppListAppsAppList.Edges, and is useful for accessing the field via an interface. +func (v *AppListAppsAppList) GetEdges() []*AppListAppsAppListEdgesApp { return v.Edges } + +// AppListAppsAppListEdgesApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type AppListAppsAppListEdgesApp struct { + AppBasic `json:"-"` +} + +// GetId returns AppListAppsAppListEdgesApp.Id, and is useful for accessing the field via an interface. +func (v *AppListAppsAppListEdgesApp) GetId() *int64 { return v.AppBasic.Id } + +// GetName returns AppListAppsAppListEdgesApp.Name, and is useful for accessing the field via an interface. +func (v *AppListAppsAppListEdgesApp) GetName() *string { return v.AppBasic.Name } + +// GetRepo returns AppListAppsAppListEdgesApp.Repo, and is useful for accessing the field via an interface. +func (v *AppListAppsAppListEdgesApp) GetRepo() *string { return v.AppBasic.Repo } + +func (v *AppListAppsAppListEdgesApp) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *AppListAppsAppListEdgesApp + graphql.NoUnmarshalJSON + } + firstPass.AppListAppsAppListEdgesApp = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.AppBasic) + if err != nil { + return err + } + return nil +} + +type __premarshalAppListAppsAppListEdgesApp struct { + Id *int64 `json:"id"` + + Name *string `json:"name"` + + Repo *string `json:"repo"` +} + +func (v *AppListAppsAppListEdgesApp) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *AppListAppsAppListEdgesApp) __premarshalJSON() (*__premarshalAppListAppsAppListEdgesApp, error) { + var retval __premarshalAppListAppsAppListEdgesApp + + retval.Id = v.AppBasic.Id + retval.Name = v.AppBasic.Name + retval.Repo = v.AppBasic.Repo + return &retval, nil +} + +// AppListResponse is returned by AppList on success. +type AppListResponse struct { + // Retrieve a paginated list of applications. + Apps *AppListAppsAppList `json:"apps"` +} + +// GetApps returns AppListResponse.Apps, and is useful for accessing the field via an interface. +func (v *AppListResponse) GetApps() *AppListAppsAppList { return v.Apps } + +// AppMappedDomainsApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type AppMappedDomainsApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*AppMappedDomainsAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns AppMappedDomainsApp.Id, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsApp) GetId() *int64 { return v.Id } + +// GetName returns AppMappedDomainsApp.Name, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsApp) GetName() *string { return v.Name } + +// GetEnvironments returns AppMappedDomainsApp.Environments, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsApp) GetEnvironments() []*AppMappedDomainsAppEnvironmentsAppEnvironment { + return v.Environments +} + +// AppMappedDomainsAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type AppMappedDomainsAppEnvironmentsAppEnvironment struct { + // The unique label for the environment. + UniqueLabel *string `json:"uniqueLabel"` + // Whether the environment is a multisite install. + IsMultisite *bool `json:"isMultisite"` + // The domains mapped to the environment. + Domains *AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainList `json:"domains"` +} + +// GetUniqueLabel returns AppMappedDomainsAppEnvironmentsAppEnvironment.UniqueLabel, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsAppEnvironmentsAppEnvironment) GetUniqueLabel() *string { + return v.UniqueLabel +} + +// GetIsMultisite returns AppMappedDomainsAppEnvironmentsAppEnvironment.IsMultisite, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsAppEnvironmentsAppEnvironment) GetIsMultisite() *bool { return v.IsMultisite } + +// GetDomains returns AppMappedDomainsAppEnvironmentsAppEnvironment.Domains, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsAppEnvironmentsAppEnvironment) GetDomains() *AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainList { + return v.Domains +} + +// AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainList includes the requested fields of the GraphQL type DomainList. +// The GraphQL type's documentation follows. +// +// A paginated list of domains. +type AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainList struct { + // The domains returned in the current page. + Nodes []*AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainListNodesDomain `json:"nodes"` +} + +// GetNodes returns AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainList.Nodes, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainList) GetNodes() []*AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainListNodesDomain { + return v.Nodes +} + +// AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainListNodesDomain includes the requested fields of the GraphQL type Domain. +// The GraphQL type's documentation follows. +// +// A domain for an environment +type AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainListNodesDomain struct { + // The domain name (i.e. something like example.com or sub.example.com) + Name string `json:"name"` + // Is this the primary domain for the environment? + IsPrimary *bool `json:"isPrimary"` +} + +// GetName returns AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainListNodesDomain.Name, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainListNodesDomain) GetName() string { + return v.Name +} + +// GetIsPrimary returns AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainListNodesDomain.IsPrimary, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsAppEnvironmentsAppEnvironmentDomainsDomainListNodesDomain) GetIsPrimary() *bool { + return v.IsPrimary +} + +// AppMappedDomainsResponse is returned by AppMappedDomains on success. +type AppMappedDomainsResponse struct { + // Retrieve a single application. + App *AppMappedDomainsApp `json:"app"` +} + +// GetApp returns AppMappedDomainsResponse.App, and is useful for accessing the field via an interface. +func (v *AppMappedDomainsResponse) GetApp() *AppMappedDomainsApp { return v.App } + +// AppMultiSiteCheckApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type AppMultiSiteCheckApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The source repository for the application in `owner/name` format. + Repo *string `json:"repo"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*AppMultiSiteCheckAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns AppMultiSiteCheckApp.Id, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckApp) GetId() *int64 { return v.Id } + +// GetName returns AppMultiSiteCheckApp.Name, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckApp) GetName() *string { return v.Name } + +// GetRepo returns AppMultiSiteCheckApp.Repo, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckApp) GetRepo() *string { return v.Repo } + +// GetEnvironments returns AppMultiSiteCheckApp.Environments, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckApp) GetEnvironments() []*AppMultiSiteCheckAppEnvironmentsAppEnvironment { + return v.Environments +} + +// AppMultiSiteCheckAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type AppMultiSiteCheckAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The application ID that owns the environment. + AppId *int64 `json:"appId"` + // The display name of the environment. + Name *string `json:"name"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // Whether the environment is a multisite install. + IsMultisite *bool `json:"isMultisite"` + // Whether the multisite install uses subdirectories. + IsSubdirectoryMultisite *bool `json:"isSubdirectoryMultisite"` +} + +// GetId returns AppMultiSiteCheckAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetAppId returns AppMultiSiteCheckAppEnvironmentsAppEnvironment.AppId, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckAppEnvironmentsAppEnvironment) GetAppId() *int64 { return v.AppId } + +// GetName returns AppMultiSiteCheckAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckAppEnvironmentsAppEnvironment) GetName() *string { return v.Name } + +// GetType returns AppMultiSiteCheckAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckAppEnvironmentsAppEnvironment) GetType() *string { return v.Type } + +// GetIsMultisite returns AppMultiSiteCheckAppEnvironmentsAppEnvironment.IsMultisite, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckAppEnvironmentsAppEnvironment) GetIsMultisite() *bool { return v.IsMultisite } + +// GetIsSubdirectoryMultisite returns AppMultiSiteCheckAppEnvironmentsAppEnvironment.IsSubdirectoryMultisite, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckAppEnvironmentsAppEnvironment) GetIsSubdirectoryMultisite() *bool { + return v.IsSubdirectoryMultisite +} + +// AppMultiSiteCheckResponse is returned by AppMultiSiteCheck on success. +type AppMultiSiteCheckResponse struct { + // Retrieve a single application. + App *AppMultiSiteCheckApp `json:"app"` +} + +// GetApp returns AppMultiSiteCheckResponse.App, and is useful for accessing the field via an interface. +func (v *AppMultiSiteCheckResponse) GetApp() *AppMultiSiteCheckApp { return v.App } + +// BackupDBCopyResponse is returned by BackupDBCopy on success. +type BackupDBCopyResponse struct { + // Start copying a database backup. + StartDBBackupCopy *BackupDBCopyStartDBBackupCopyAppEnvironmentStartDBBackupCopyPayload `json:"startDBBackupCopy"` +} + +// GetStartDBBackupCopy returns BackupDBCopyResponse.StartDBBackupCopy, and is useful for accessing the field via an interface. +func (v *BackupDBCopyResponse) GetStartDBBackupCopy() *BackupDBCopyStartDBBackupCopyAppEnvironmentStartDBBackupCopyPayload { + return v.StartDBBackupCopy +} + +// BackupDBCopyStartDBBackupCopyAppEnvironmentStartDBBackupCopyPayload includes the requested fields of the GraphQL type AppEnvironmentStartDBBackupCopyPayload. +// The GraphQL type's documentation follows. +// +// The result of starting a database backup copy. +type BackupDBCopyStartDBBackupCopyAppEnvironmentStartDBBackupCopyPayload struct { + // A human-readable result message. + Message *string `json:"message"` + // Whether the operation succeeded. + Success *bool `json:"success"` +} + +// GetMessage returns BackupDBCopyStartDBBackupCopyAppEnvironmentStartDBBackupCopyPayload.Message, and is useful for accessing the field via an interface. +func (v *BackupDBCopyStartDBBackupCopyAppEnvironmentStartDBBackupCopyPayload) GetMessage() *string { + return v.Message +} + +// GetSuccess returns BackupDBCopyStartDBBackupCopyAppEnvironmentStartDBBackupCopyPayload.Success, and is useful for accessing the field via an interface. +func (v *BackupDBCopyStartDBBackupCopyAppEnvironmentStartDBBackupCopyPayload) GetSuccess() *bool { + return v.Success +} + +// DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayload includes the requested fields of the GraphQL type EnvironmentVariablesPayload. +// The GraphQL type's documentation follows. +// +// The updated environment variable list after a mutation. +type DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayload struct { + // The environment variables currently configured on the environment. + EnvironmentVariables *DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList `json:"environmentVariables"` +} + +// GetEnvironmentVariables returns DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayload.EnvironmentVariables, and is useful for accessing the field via an interface. +func (v *DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayload) GetEnvironmentVariables() *DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList { + return v.EnvironmentVariables +} + +// DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList includes the requested fields of the GraphQL type EnvironmentVariablesList. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variables / constants +type DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList struct { + // The total number of environment variables for this environment + Total *int64 `json:"total"` + // The environment variables for this environment + Nodes []*DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable `json:"nodes"` +} + +// GetTotal returns DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList.Total, and is useful for accessing the field via an interface. +func (v *DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList) GetTotal() *int64 { + return v.Total +} + +// GetNodes returns DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList.Nodes, and is useful for accessing the field via an interface. +func (v *DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesList) GetNodes() []*DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable { + return v.Nodes +} + +// DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable includes the requested fields of the GraphQL type EnvironmentVariable. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variable / constant +type DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable struct { + // Environment variable name + Name string `json:"name"` +} + +// GetName returns DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable.Name, and is useful for accessing the field via an interface. +func (v *DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayloadEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable) GetName() string { + return v.Name +} + +// DeleteEnvironmentVariableResponse is returned by DeleteEnvironmentVariable on success. +type DeleteEnvironmentVariableResponse struct { + // Delete an environment variable from an application environment. + DeleteEnvironmentVariable *DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayload `json:"deleteEnvironmentVariable"` +} + +// GetDeleteEnvironmentVariable returns DeleteEnvironmentVariableResponse.DeleteEnvironmentVariable, and is useful for accessing the field via an interface. +func (v *DeleteEnvironmentVariableResponse) GetDeleteEnvironmentVariable() *DeleteEnvironmentVariableDeleteEnvironmentVariableEnvironmentVariablesPayload { + return v.DeleteEnvironmentVariable +} + +// DevEnvAppInfoApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type DevEnvAppInfoApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*DevEnvAppInfoAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns DevEnvAppInfoApp.Id, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoApp) GetId() *int64 { return v.Id } + +// GetName returns DevEnvAppInfoApp.Name, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoApp) GetName() *string { return v.Name } + +// GetEnvironments returns DevEnvAppInfoApp.Environments, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoApp) GetEnvironments() []*DevEnvAppInfoAppEnvironmentsAppEnvironment { + return v.Environments +} + +// DevEnvAppInfoAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type DevEnvAppInfoAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The application ID that owns the environment. + AppId *int64 `json:"appId"` + // The display name of the environment. + Name *string `json:"name"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // Whether the environment is a multisite install. + IsMultisite *bool `json:"isMultisite"` + // The primary domain for the environment. + PrimaryDomain *DevEnvAppInfoAppEnvironmentsAppEnvironmentPrimaryDomain `json:"primaryDomain"` + // The environment variables configured for the environment. + EnvironmentVariables *DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList `json:"environmentVariables"` + // The software settings for the environment. + SoftwareSettings *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettings `json:"softwareSettings"` +} + +// GetId returns DevEnvAppInfoAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetAppId returns DevEnvAppInfoAppEnvironmentsAppEnvironment.AppId, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironment) GetAppId() *int64 { return v.AppId } + +// GetName returns DevEnvAppInfoAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironment) GetName() *string { return v.Name } + +// GetType returns DevEnvAppInfoAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironment) GetType() *string { return v.Type } + +// GetIsMultisite returns DevEnvAppInfoAppEnvironmentsAppEnvironment.IsMultisite, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironment) GetIsMultisite() *bool { return v.IsMultisite } + +// GetPrimaryDomain returns DevEnvAppInfoAppEnvironmentsAppEnvironment.PrimaryDomain, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironment) GetPrimaryDomain() *DevEnvAppInfoAppEnvironmentsAppEnvironmentPrimaryDomain { + return v.PrimaryDomain +} + +// GetEnvironmentVariables returns DevEnvAppInfoAppEnvironmentsAppEnvironment.EnvironmentVariables, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironment) GetEnvironmentVariables() *DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList { + return v.EnvironmentVariables +} + +// GetSoftwareSettings returns DevEnvAppInfoAppEnvironmentsAppEnvironment.SoftwareSettings, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironment) GetSoftwareSettings() *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettings { + return v.SoftwareSettings +} + +// DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList includes the requested fields of the GraphQL type EnvironmentVariablesList. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variables / constants +type DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList struct { + // The environment variables for this environment + Nodes []*DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable `json:"nodes"` +} + +// GetNodes returns DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList.Nodes, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList) GetNodes() []*DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable { + return v.Nodes +} + +// DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable includes the requested fields of the GraphQL type EnvironmentVariable. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variable / constant +type DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable struct { + // Environment variable name + Name string `json:"name"` +} + +// GetName returns DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable.Name, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable) GetName() string { + return v.Name +} + +// DevEnvAppInfoAppEnvironmentsAppEnvironmentPrimaryDomain includes the requested fields of the GraphQL type Domain. +// The GraphQL type's documentation follows. +// +// A domain for an environment +type DevEnvAppInfoAppEnvironmentsAppEnvironmentPrimaryDomain struct { + // The domain name (i.e. something like example.com or sub.example.com) + Name string `json:"name"` +} + +// GetName returns DevEnvAppInfoAppEnvironmentsAppEnvironmentPrimaryDomain.Name, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironmentPrimaryDomain) GetName() string { return v.Name } + +// DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettings includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettings. +// The GraphQL type's documentation follows. +// +// Available software settings for an application environment. +type DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettings struct { + // The PHP software settings. + Php *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware `json:"php"` + // The WordPress software settings. + Wordpress *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware `json:"wordpress"` +} + +// GetPhp returns DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettings.Php, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettings) GetPhp() *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware { + return v.Php +} + +// GetWordpress returns DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettings.Wordpress, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettings) GetWordpress() *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware { + return v.Wordpress +} + +// DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware struct { + // The currently selected version. + Current *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` +} + +// GetCurrent returns DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion { + return v.Current +} + +// DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsVersion. +// The GraphQL type's documentation follows. +// +// A software version option available for an environment. +type DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion struct { + // The version identifier. + Version string `json:"version"` +} + +// GetVersion returns DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion.Version, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion) GetVersion() string { + return v.Version +} + +// DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware struct { + // The currently selected version. + Current *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` +} + +// GetCurrent returns DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion { + return v.Current +} + +// DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsVersion. +// The GraphQL type's documentation follows. +// +// A software version option available for an environment. +type DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion struct { + // The version identifier. + Version string `json:"version"` +} + +// GetVersion returns DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion.Version, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftwareCurrentAppEnvironmentSoftwareSettingsVersion) GetVersion() string { + return v.Version +} + +// DevEnvAppInfoResponse is returned by DevEnvAppInfo on success. +type DevEnvAppInfoResponse struct { + // Retrieve a single application. + App *DevEnvAppInfoApp `json:"app"` +} + +// GetApp returns DevEnvAppInfoResponse.App, and is useful for accessing the field via an interface. +func (v *DevEnvAppInfoResponse) GetApp() *DevEnvAppInfoApp { return v.App } + +// DevEnvSyncSitesApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type DevEnvSyncSitesApp struct { + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*DevEnvSyncSitesAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetEnvironments returns DevEnvSyncSitesApp.Environments, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesApp) GetEnvironments() []*DevEnvSyncSitesAppEnvironmentsAppEnvironment { + return v.Environments +} + +// DevEnvSyncSitesAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type DevEnvSyncSitesAppEnvironmentsAppEnvironment struct { + // Get WordPress Site Details from SDS + WpSitesSDS *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList `json:"wpSitesSDS"` +} + +// GetWpSitesSDS returns DevEnvSyncSitesAppEnvironmentsAppEnvironment.WpSitesSDS, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironment) GetWpSitesSDS() *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList { + return v.WpSitesSDS +} + +// DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList includes the requested fields of the GraphQL type WPSiteList. +// The GraphQL type's documentation follows. +// +// A paginated list of WordPress sites. +type DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList struct { + // The total number of matching WordPress sites. + Total *int64 `json:"total"` + // The cursor for the next page of WordPress sites. + NextCursor *string `json:"nextCursor"` + // The WordPress sites returned in the current page. + Nodes []*DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite `json:"nodes"` +} + +// GetTotal returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList.Total, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList) GetTotal() *int64 { + return v.Total +} + +// GetNextCursor returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList.NextCursor, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList) GetNextCursor() *string { + return v.NextCursor +} + +// GetNodes returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList.Nodes, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList) GetNodes() []*DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite { + return v.Nodes +} + +// DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite includes the requested fields of the GraphQL type WPSite. +// The GraphQL type's documentation follows. +// +// A WordPress site or subsite within an environment. +type DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite struct { + // WordPress Site/Blog ID + BlogId *int64 `json:"blogId"` + // WordPress Home URL option + HomeUrl *string `json:"homeUrl"` + // WordPress Site URL option + SiteUrl *string `json:"siteUrl"` +} + +// GetBlogId returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite.BlogId, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite) GetBlogId() *int64 { + return v.BlogId +} + +// GetHomeUrl returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite.HomeUrl, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite) GetHomeUrl() *string { + return v.HomeUrl +} + +// GetSiteUrl returns DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite.SiteUrl, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite) GetSiteUrl() *string { + return v.SiteUrl +} + +// DevEnvSyncSitesResponse is returned by DevEnvSyncSites on success. +type DevEnvSyncSitesResponse struct { + // Retrieve a single application. + App *DevEnvSyncSitesApp `json:"app"` +} + +// GetApp returns DevEnvSyncSitesResponse.App, and is useful for accessing the field via an interface. +func (v *DevEnvSyncSitesResponse) GetApp() *DevEnvSyncSitesApp { return v.App } + +// EnablePhpMyAdminEnablePHPMyAdminEnablePhpMyAdminPayload includes the requested fields of the GraphQL type EnablePhpMyAdminPayload. +// The GraphQL type's documentation follows. +// +// The result of enabling phpMyAdmin. +type EnablePhpMyAdminEnablePHPMyAdminEnablePhpMyAdminPayload struct { + // Whether phpMyAdmin was enabled successfully. + Success *bool `json:"success"` +} + +// GetSuccess returns EnablePhpMyAdminEnablePHPMyAdminEnablePhpMyAdminPayload.Success, and is useful for accessing the field via an interface. +func (v *EnablePhpMyAdminEnablePHPMyAdminEnablePhpMyAdminPayload) GetSuccess() *bool { + return v.Success +} + +// Input for enabling phpMyAdmin. +type EnablePhpMyAdminInput struct { + // The environment ID. + EnvironmentId int64 `json:"environmentId"` +} + +// GetEnvironmentId returns EnablePhpMyAdminInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *EnablePhpMyAdminInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// EnablePhpMyAdminResponse is returned by EnablePhpMyAdmin on success. +type EnablePhpMyAdminResponse struct { + // Enable phpMyAdmin for an environment. + EnablePHPMyAdmin *EnablePhpMyAdminEnablePHPMyAdminEnablePhpMyAdminPayload `json:"enablePHPMyAdmin"` +} + +// GetEnablePHPMyAdmin returns EnablePhpMyAdminResponse.EnablePHPMyAdmin, and is useful for accessing the field via an interface. +func (v *EnablePhpMyAdminResponse) GetEnablePHPMyAdmin() *EnablePhpMyAdminEnablePHPMyAdminEnablePhpMyAdminPayload { + return v.EnablePHPMyAdmin +} + +// Input for creating, updating, or deleting an environment variable. +type EnvironmentVariableInput struct { + // The unique ID of the Application + ApplicationId int64 `json:"applicationId"` + // The unique ID of the environment + EnvironmentId int64 `json:"environmentId"` + // Environment variable name (must consist of uppercase letters, numbers, and underscore + Name string `json:"name"` + // Whether to reload the site manifest after the operation + ReloadManifest *bool `json:"reloadManifest"` + // Environment variable value + Value string `json:"value"` +} + +// GetApplicationId returns EnvironmentVariableInput.ApplicationId, and is useful for accessing the field via an interface. +func (v *EnvironmentVariableInput) GetApplicationId() int64 { return v.ApplicationId } + +// GetEnvironmentId returns EnvironmentVariableInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *EnvironmentVariableInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GetName returns EnvironmentVariableInput.Name, and is useful for accessing the field via an interface. +func (v *EnvironmentVariableInput) GetName() string { return v.Name } + +// GetReloadManifest returns EnvironmentVariableInput.ReloadManifest, and is useful for accessing the field via an interface. +func (v *EnvironmentVariableInput) GetReloadManifest() *bool { return v.ReloadManifest } + +// GetValue returns EnvironmentVariableInput.Value, and is useful for accessing the field via an interface. +func (v *EnvironmentVariableInput) GetValue() string { return v.Value } + +// GenerateDBBackupCopyUrlGenerateDBBackupCopyUrlAppEnvironmentGenerateDBBackupCopyUrlPayload includes the requested fields of the GraphQL type AppEnvironmentGenerateDBBackupCopyUrlPayload. +// The GraphQL type's documentation follows. +// +// The result of generating a database backup copy download URL. +type GenerateDBBackupCopyUrlGenerateDBBackupCopyUrlAppEnvironmentGenerateDBBackupCopyUrlPayload struct { + // The generated download URL. + Url *string `json:"url"` + // Whether the operation succeeded. + Success *bool `json:"success"` +} + +// GetUrl returns GenerateDBBackupCopyUrlGenerateDBBackupCopyUrlAppEnvironmentGenerateDBBackupCopyUrlPayload.Url, and is useful for accessing the field via an interface. +func (v *GenerateDBBackupCopyUrlGenerateDBBackupCopyUrlAppEnvironmentGenerateDBBackupCopyUrlPayload) GetUrl() *string { + return v.Url +} + +// GetSuccess returns GenerateDBBackupCopyUrlGenerateDBBackupCopyUrlAppEnvironmentGenerateDBBackupCopyUrlPayload.Success, and is useful for accessing the field via an interface. +func (v *GenerateDBBackupCopyUrlGenerateDBBackupCopyUrlAppEnvironmentGenerateDBBackupCopyUrlPayload) GetSuccess() *bool { + return v.Success +} + +// GenerateDBBackupCopyUrlResponse is returned by GenerateDBBackupCopyUrl on success. +type GenerateDBBackupCopyUrlResponse struct { + // Generate a presigned download URL for a copied database backup. + GenerateDBBackupCopyUrl *GenerateDBBackupCopyUrlGenerateDBBackupCopyUrlAppEnvironmentGenerateDBBackupCopyUrlPayload `json:"generateDBBackupCopyUrl"` +} + +// GetGenerateDBBackupCopyUrl returns GenerateDBBackupCopyUrlResponse.GenerateDBBackupCopyUrl, and is useful for accessing the field via an interface. +func (v *GenerateDBBackupCopyUrlResponse) GetGenerateDBBackupCopyUrl() *GenerateDBBackupCopyUrlGenerateDBBackupCopyUrlAppEnvironmentGenerateDBBackupCopyUrlPayload { + return v.GenerateDBBackupCopyUrl +} + +// GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload includes the requested fields of the GraphQL type AppEnvironmentLiveBackupCopyDownloadURLPayload. +// The GraphQL type's documentation follows. +// +// The result of generating a live backup copy download URL. +type GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload struct { + // Whether the operation succeeded. + Success bool `json:"success"` + // The generated download URL. + Url *string `json:"url"` + // Whether the live backup copy is still processing. + Processing bool `json:"processing"` + // The size of the downloadable copy in bytes. + Size *int64 `json:"size"` +} + +// GetSuccess returns GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload.Success, and is useful for accessing the field via an interface. +func (v *GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload) GetSuccess() bool { + return v.Success +} + +// GetUrl returns GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload.Url, and is useful for accessing the field via an interface. +func (v *GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload) GetUrl() *string { + return v.Url +} + +// GetProcessing returns GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload.Processing, and is useful for accessing the field via an interface. +func (v *GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload) GetProcessing() bool { + return v.Processing +} + +// GetSize returns GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload.Size, and is useful for accessing the field via an interface. +func (v *GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload) GetSize() *int64 { + return v.Size +} + +// GenerateLiveBackupCopyDownloadURLResponse is returned by GenerateLiveBackupCopyDownloadURL on success. +type GenerateLiveBackupCopyDownloadURLResponse struct { + // Generate a live backup copy download URL. + GenerateLiveBackupCopyDownloadURL *GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload `json:"generateLiveBackupCopyDownloadURL"` +} + +// GetGenerateLiveBackupCopyDownloadURL returns GenerateLiveBackupCopyDownloadURLResponse.GenerateLiveBackupCopyDownloadURL, and is useful for accessing the field via an interface. +func (v *GenerateLiveBackupCopyDownloadURLResponse) GetGenerateLiveBackupCopyDownloadURL() *GenerateLiveBackupCopyDownloadURLGenerateLiveBackupCopyDownloadURLAppEnvironmentLiveBackupCopyDownloadURLPayload { + return v.GenerateLiveBackupCopyDownloadURL +} + +// GeneratePhpMyAdminAccessGeneratePHPMyAdminAccessGeneratePhpMyAdminAccessPayload includes the requested fields of the GraphQL type GeneratePhpMyAdminAccessPayload. +// The GraphQL type's documentation follows. +// +// The result of generating phpMyAdmin access. +type GeneratePhpMyAdminAccessGeneratePHPMyAdminAccessGeneratePhpMyAdminAccessPayload struct { + // The generated phpMyAdmin URL. + Url *string `json:"url"` +} + +// GetUrl returns GeneratePhpMyAdminAccessGeneratePHPMyAdminAccessGeneratePhpMyAdminAccessPayload.Url, and is useful for accessing the field via an interface. +func (v *GeneratePhpMyAdminAccessGeneratePHPMyAdminAccessGeneratePhpMyAdminAccessPayload) GetUrl() *string { + return v.Url +} + +// Input for generating phpMyAdmin access. +type GeneratePhpMyAdminAccessInput struct { + // The environment ID. + EnvironmentId int64 `json:"environmentId"` +} + +// GetEnvironmentId returns GeneratePhpMyAdminAccessInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *GeneratePhpMyAdminAccessInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GeneratePhpMyAdminAccessResponse is returned by GeneratePhpMyAdminAccess on success. +type GeneratePhpMyAdminAccessResponse struct { + // Generate temporary phpMyAdmin access for an environment. + GeneratePHPMyAdminAccess *GeneratePhpMyAdminAccessGeneratePHPMyAdminAccessGeneratePhpMyAdminAccessPayload `json:"generatePHPMyAdminAccess"` +} + +// GetGeneratePHPMyAdminAccess returns GeneratePhpMyAdminAccessResponse.GeneratePHPMyAdminAccess, and is useful for accessing the field via an interface. +func (v *GeneratePhpMyAdminAccessResponse) GetGeneratePHPMyAdminAccess() *GeneratePhpMyAdminAccessGeneratePHPMyAdminAccessGeneratePhpMyAdminAccessPayload { + return v.GeneratePHPMyAdminAccess +} + +// GetAppLogsApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type GetAppLogsApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*GetAppLogsAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns GetAppLogsApp.Id, and is useful for accessing the field via an interface. +func (v *GetAppLogsApp) GetId() *int64 { return v.Id } + +// GetEnvironments returns GetAppLogsApp.Environments, and is useful for accessing the field via an interface. +func (v *GetAppLogsApp) GetEnvironments() []*GetAppLogsAppEnvironmentsAppEnvironment { + return v.Environments +} + +// GetAppLogsAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type GetAppLogsAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // Application and platform logs for the environment. Use `type: app` or `type: batch`. Returns `pollingDelaySeconds` to guide incremental polling. + Logs *GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList `json:"logs"` +} + +// GetId returns GetAppLogsAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *GetAppLogsAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetLogs returns GetAppLogsAppEnvironmentsAppEnvironment.Logs, and is useful for accessing the field via an interface. +func (v *GetAppLogsAppEnvironmentsAppEnvironment) GetLogs() *GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList { + return v.Logs +} + +// GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList includes the requested fields of the GraphQL type AppEnvironmentLogsList. +// The GraphQL type's documentation follows. +// +// A paginated list of environment log entries. +type GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList struct { + // The log entries returned in the current page. + Nodes []*GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsListNodesAppEnvironmentLog `json:"nodes"` + // The cursor for the next page of log entries. + NextCursor *string `json:"nextCursor"` + // The suggested polling delay before fetching logs again. + PollingDelaySeconds int64 `json:"pollingDelaySeconds"` +} + +// GetNodes returns GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList.Nodes, and is useful for accessing the field via an interface. +func (v *GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList) GetNodes() []*GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsListNodesAppEnvironmentLog { + return v.Nodes +} + +// GetNextCursor returns GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList.NextCursor, and is useful for accessing the field via an interface. +func (v *GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList) GetNextCursor() *string { + return v.NextCursor +} + +// GetPollingDelaySeconds returns GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList.PollingDelaySeconds, and is useful for accessing the field via an interface. +func (v *GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList) GetPollingDelaySeconds() int64 { + return v.PollingDelaySeconds +} + +// GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsListNodesAppEnvironmentLog includes the requested fields of the GraphQL type AppEnvironmentLog. +// The GraphQL type's documentation follows. +// +// A single environment log entry. +type GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsListNodesAppEnvironmentLog struct { + // When the log entry was recorded. + Timestamp *string `json:"timestamp"` + // The log message. + Message *string `json:"message"` +} + +// GetTimestamp returns GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsListNodesAppEnvironmentLog.Timestamp, and is useful for accessing the field via an interface. +func (v *GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsListNodesAppEnvironmentLog) GetTimestamp() *string { + return v.Timestamp +} + +// GetMessage returns GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsListNodesAppEnvironmentLog.Message, and is useful for accessing the field via an interface. +func (v *GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsListNodesAppEnvironmentLog) GetMessage() *string { + return v.Message +} + +// GetAppLogsResponse is returned by GetAppLogs on success. +type GetAppLogsResponse struct { + // Retrieve a single application. + App *GetAppLogsApp `json:"app"` +} + +// GetApp returns GetAppLogsResponse.App, and is useful for accessing the field via an interface. +func (v *GetAppLogsResponse) GetApp() *GetAppLogsApp { return v.App } + +// GetAppSlowlogsApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type GetAppSlowlogsApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*GetAppSlowlogsAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns GetAppSlowlogsApp.Id, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsApp) GetId() *int64 { return v.Id } + +// GetEnvironments returns GetAppSlowlogsApp.Environments, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsApp) GetEnvironments() []*GetAppSlowlogsAppEnvironmentsAppEnvironment { + return v.Environments +} + +// GetAppSlowlogsAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type GetAppSlowlogsAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // Database slow query logs for the environment. + Slowlogs *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList `json:"slowlogs"` +} + +// GetId returns GetAppSlowlogsAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetSlowlogs returns GetAppSlowlogsAppEnvironmentsAppEnvironment.Slowlogs, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironment) GetSlowlogs() *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList { + return v.Slowlogs +} + +// GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList includes the requested fields of the GraphQL type AppEnvironmentSlowlogsList. +// The GraphQL type's documentation follows. +// +// A paginated list of slow log entries. +type GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList struct { + // The slow log entries returned in the current page. + Nodes []*GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog `json:"nodes"` + // The cursor for the next page of slow log entries. + NextCursor *string `json:"nextCursor"` + // The suggested polling delay before fetching slow logs again. + PollingDelaySeconds int64 `json:"pollingDelaySeconds"` +} + +// GetNodes returns GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList.Nodes, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList) GetNodes() []*GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog { + return v.Nodes +} + +// GetNextCursor returns GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList.NextCursor, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList) GetNextCursor() *string { + return v.NextCursor +} + +// GetPollingDelaySeconds returns GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList.PollingDelaySeconds, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsList) GetPollingDelaySeconds() int64 { + return v.PollingDelaySeconds +} + +// GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog includes the requested fields of the GraphQL type AppEnvironmentSlowlog. +// The GraphQL type's documentation follows. +// +// A single slow query log entry. +type GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog struct { + // When the slow query was recorded. + Timestamp *string `json:"timestamp"` + // The number of rows returned by the query. + RowsSent *string `json:"rowsSent"` + // The number of rows examined by the query. + RowsExamined *string `json:"rowsExamined"` + // How long the query took to execute. + QueryTime *string `json:"queryTime"` + // The request URI associated with the slow query. + RequestUri *string `json:"requestUri"` + // The SQL query text. + Query *string `json:"query"` +} + +// GetTimestamp returns GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog.Timestamp, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog) GetTimestamp() *string { + return v.Timestamp +} + +// GetRowsSent returns GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog.RowsSent, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog) GetRowsSent() *string { + return v.RowsSent +} + +// GetRowsExamined returns GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog.RowsExamined, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog) GetRowsExamined() *string { + return v.RowsExamined +} + +// GetQueryTime returns GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog.QueryTime, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog) GetQueryTime() *string { + return v.QueryTime +} + +// GetRequestUri returns GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog.RequestUri, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog) GetRequestUri() *string { + return v.RequestUri +} + +// GetQuery returns GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog.Query, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsAppEnvironmentsAppEnvironmentSlowlogsAppEnvironmentSlowlogsListNodesAppEnvironmentSlowlog) GetQuery() *string { + return v.Query +} + +// GetAppSlowlogsResponse is returned by GetAppSlowlogs on success. +type GetAppSlowlogsResponse struct { + // Retrieve a single application. + App *GetAppSlowlogsApp `json:"app"` +} + +// GetApp returns GetAppSlowlogsResponse.App, and is useful for accessing the field via an interface. +func (v *GetAppSlowlogsResponse) GetApp() *GetAppSlowlogsApp { return v.App } + +// GetEnvironmentVariablesApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type GetEnvironmentVariablesApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*GetEnvironmentVariablesAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns GetEnvironmentVariablesApp.Id, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesApp) GetId() *int64 { return v.Id } + +// GetEnvironments returns GetEnvironmentVariablesApp.Environments, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesApp) GetEnvironments() []*GetEnvironmentVariablesAppEnvironmentsAppEnvironment { + return v.Environments +} + +// GetEnvironmentVariablesAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type GetEnvironmentVariablesAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The environment variables configured for the environment. + EnvironmentVariables *GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList `json:"environmentVariables"` +} + +// GetId returns GetEnvironmentVariablesAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetEnvironmentVariables returns GetEnvironmentVariablesAppEnvironmentsAppEnvironment.EnvironmentVariables, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesAppEnvironmentsAppEnvironment) GetEnvironmentVariables() *GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList { + return v.EnvironmentVariables +} + +// GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList includes the requested fields of the GraphQL type EnvironmentVariablesList. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variables / constants +type GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList struct { + // The total number of environment variables for this environment + Total *int64 `json:"total"` + // The environment variables for this environment + Nodes []*GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable `json:"nodes"` +} + +// GetTotal returns GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList.Total, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList) GetTotal() *int64 { + return v.Total +} + +// GetNodes returns GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList.Nodes, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList) GetNodes() []*GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable { + return v.Nodes +} + +// GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable includes the requested fields of the GraphQL type EnvironmentVariable. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variable / constant +type GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable struct { + // Environment variable name + Name string `json:"name"` +} + +// GetName returns GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable.Name, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable) GetName() string { + return v.Name +} + +// GetEnvironmentVariablesResponse is returned by GetEnvironmentVariables on success. +type GetEnvironmentVariablesResponse struct { + // Retrieve a single application. + App *GetEnvironmentVariablesApp `json:"app"` +} + +// GetApp returns GetEnvironmentVariablesResponse.App, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesResponse) GetApp() *GetEnvironmentVariablesApp { return v.App } + +// GetEnvironmentVariablesWithValuesApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type GetEnvironmentVariablesWithValuesApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns GetEnvironmentVariablesWithValuesApp.Id, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesWithValuesApp) GetId() *int64 { return v.Id } + +// GetEnvironments returns GetEnvironmentVariablesWithValuesApp.Environments, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesWithValuesApp) GetEnvironments() []*GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironment { + return v.Environments +} + +// GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The environment variables configured for the environment. + EnvironmentVariables *GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList `json:"environmentVariables"` +} + +// GetId returns GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetEnvironmentVariables returns GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironment.EnvironmentVariables, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironment) GetEnvironmentVariables() *GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList { + return v.EnvironmentVariables +} + +// GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList includes the requested fields of the GraphQL type EnvironmentVariablesList. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variables / constants +type GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList struct { + // The total number of environment variables for this environment + Total *int64 `json:"total"` + // The environment variables for this environment + Nodes []*GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable `json:"nodes"` +} + +// GetTotal returns GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList.Total, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList) GetTotal() *int64 { + return v.Total +} + +// GetNodes returns GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList.Nodes, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesList) GetNodes() []*GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable { + return v.Nodes +} + +// GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable includes the requested fields of the GraphQL type EnvironmentVariable. +// The GraphQL type's documentation follows. +// +// Customer-provided environment variable / constant +type GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable struct { + // Environment variable name + Name string `json:"name"` + // Environment variable value + Value *string `json:"value"` +} + +// GetName returns GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable.Name, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable) GetName() string { + return v.Name +} + +// GetValue returns GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable.Value, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesWithValuesAppEnvironmentsAppEnvironmentEnvironmentVariablesEnvironmentVariablesListNodesEnvironmentVariable) GetValue() *string { + return v.Value +} + +// GetEnvironmentVariablesWithValuesResponse is returned by GetEnvironmentVariablesWithValues on success. +type GetEnvironmentVariablesWithValuesResponse struct { + // Retrieve a single application. + App *GetEnvironmentVariablesWithValuesApp `json:"app"` +} + +// GetApp returns GetEnvironmentVariablesWithValuesResponse.App, and is useful for accessing the field via an interface. +func (v *GetEnvironmentVariablesWithValuesResponse) GetApp() *GetEnvironmentVariablesWithValuesApp { + return v.App +} + +// ImportSQLEnvInfoApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type ImportSQLEnvInfoApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The internal numeric identifier for the application type. + TypeId *int64 `json:"typeId"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*ImportSQLEnvInfoAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns ImportSQLEnvInfoApp.Id, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoApp) GetId() *int64 { return v.Id } + +// GetName returns ImportSQLEnvInfoApp.Name, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoApp) GetName() *string { return v.Name } + +// GetTypeId returns ImportSQLEnvInfoApp.TypeId, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoApp) GetTypeId() *int64 { return v.TypeId } + +// GetEnvironments returns ImportSQLEnvInfoApp.Environments, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoApp) GetEnvironments() []*ImportSQLEnvInfoAppEnvironmentsAppEnvironment { + return v.Environments +} + +// ImportSQLEnvInfoAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type ImportSQLEnvInfoAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The application ID that owns the environment. + AppId *int64 `json:"appId"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // The display name of the environment. + Name *string `json:"name"` + // Whether the environment has been launched. + Launched *bool `json:"launched"` + // Whether the environment runs on Kubernetes. + IsK8sResident *bool `json:"isK8sResident"` + // The primary domain for the environment. + PrimaryDomain *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain `json:"primaryDomain"` + // The current import status for the environment. + ImportStatus *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentImportStatus `json:"importStatus"` + // Get WordPress Site Details from SDS + WpSitesSDS *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList `json:"wpSitesSDS"` +} + +// GetId returns ImportSQLEnvInfoAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetAppId returns ImportSQLEnvInfoAppEnvironmentsAppEnvironment.AppId, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironment) GetAppId() *int64 { return v.AppId } + +// GetType returns ImportSQLEnvInfoAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironment) GetType() *string { return v.Type } + +// GetName returns ImportSQLEnvInfoAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironment) GetName() *string { return v.Name } + +// GetLaunched returns ImportSQLEnvInfoAppEnvironmentsAppEnvironment.Launched, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironment) GetLaunched() *bool { return v.Launched } + +// GetIsK8sResident returns ImportSQLEnvInfoAppEnvironmentsAppEnvironment.IsK8sResident, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironment) GetIsK8sResident() *bool { + return v.IsK8sResident +} + +// GetPrimaryDomain returns ImportSQLEnvInfoAppEnvironmentsAppEnvironment.PrimaryDomain, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironment) GetPrimaryDomain() *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain { + return v.PrimaryDomain +} + +// GetImportStatus returns ImportSQLEnvInfoAppEnvironmentsAppEnvironment.ImportStatus, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironment) GetImportStatus() *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentImportStatus { + return v.ImportStatus +} + +// GetWpSitesSDS returns ImportSQLEnvInfoAppEnvironmentsAppEnvironment.WpSitesSDS, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironment) GetWpSitesSDS() *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList { + return v.WpSitesSDS +} + +// ImportSQLEnvInfoAppEnvironmentsAppEnvironmentImportStatus includes the requested fields of the GraphQL type AppEnvironmentImportStatus. +// The GraphQL type's documentation follows. +// +// The current status of an environment import. +type ImportSQLEnvInfoAppEnvironmentsAppEnvironmentImportStatus struct { + // Whether any database operation is currently in progress. + DbOperationInProgress *bool `json:"dbOperationInProgress"` + // Whether an import is currently in progress. + ImportInProgress *bool `json:"importInProgress"` +} + +// GetDbOperationInProgress returns ImportSQLEnvInfoAppEnvironmentsAppEnvironmentImportStatus.DbOperationInProgress, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentImportStatus) GetDbOperationInProgress() *bool { + return v.DbOperationInProgress +} + +// GetImportInProgress returns ImportSQLEnvInfoAppEnvironmentsAppEnvironmentImportStatus.ImportInProgress, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentImportStatus) GetImportInProgress() *bool { + return v.ImportInProgress +} + +// ImportSQLEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain includes the requested fields of the GraphQL type Domain. +// The GraphQL type's documentation follows. +// +// A domain for an environment +type ImportSQLEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain struct { + // The domain name (i.e. something like example.com or sub.example.com) + Name string `json:"name"` +} + +// GetName returns ImportSQLEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain.Name, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain) GetName() string { return v.Name } + +// ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList includes the requested fields of the GraphQL type WPSiteList. +// The GraphQL type's documentation follows. +// +// A paginated list of WordPress sites. +type ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList struct { + // The WordPress sites returned in the current page. + Nodes []*ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite `json:"nodes"` +} + +// GetNodes returns ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList.Nodes, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteList) GetNodes() []*ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite { + return v.Nodes +} + +// ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite includes the requested fields of the GraphQL type WPSite. +// The GraphQL type's documentation follows. +// +// A WordPress site or subsite within an environment. +type ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite struct { + // WordPress Home URL option + HomeUrl *string `json:"homeUrl"` + // [DEPRECATING SOON] Alias for blogId + Id *int64 `json:"id"` +} + +// GetHomeUrl returns ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite.HomeUrl, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite) GetHomeUrl() *string { + return v.HomeUrl +} + +// GetId returns ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite.Id, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoAppEnvironmentsAppEnvironmentWpSitesSDSWPSiteListNodesWPSite) GetId() *int64 { + return v.Id +} + +// ImportSQLEnvInfoResponse is returned by ImportSQLEnvInfo on success. +type ImportSQLEnvInfoResponse struct { + // Retrieve a single application. + App *ImportSQLEnvInfoApp `json:"app"` +} + +// GetApp returns ImportSQLEnvInfoResponse.App, and is useful for accessing the field via an interface. +func (v *ImportSQLEnvInfoResponse) GetApp() *ImportSQLEnvInfoApp { return v.App } + +// ImportSQLProgressApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type ImportSQLProgressApp struct { + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*ImportSQLProgressAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetEnvironments returns ImportSQLProgressApp.Environments, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressApp) GetEnvironments() []*ImportSQLProgressAppEnvironmentsAppEnvironment { + return v.Environments +} + +// ImportSQLProgressAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type ImportSQLProgressAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // Whether the environment runs on Kubernetes. + IsK8sResident *bool `json:"isK8sResident"` + // Whether the environment has been launched. + Launched *bool `json:"launched"` + // Jobs running on or related to the environment. + Jobs []*ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface `json:"-"` + // The current import status for the environment. + ImportStatus *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus `json:"importStatus"` +} + +// GetId returns ImportSQLProgressAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetIsK8sResident returns ImportSQLProgressAppEnvironmentsAppEnvironment.IsK8sResident, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironment) GetIsK8sResident() *bool { + return v.IsK8sResident +} + +// GetLaunched returns ImportSQLProgressAppEnvironmentsAppEnvironment.Launched, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironment) GetLaunched() *bool { return v.Launched } + +// GetJobs returns ImportSQLProgressAppEnvironmentsAppEnvironment.Jobs, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironment) GetJobs() []*ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface { + return v.Jobs +} + +// GetImportStatus returns ImportSQLProgressAppEnvironmentsAppEnvironment.ImportStatus, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironment) GetImportStatus() *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus { + return v.ImportStatus +} + +func (v *ImportSQLProgressAppEnvironmentsAppEnvironment) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *ImportSQLProgressAppEnvironmentsAppEnvironment + Jobs []json.RawMessage `json:"jobs"` + graphql.NoUnmarshalJSON + } + firstPass.ImportSQLProgressAppEnvironmentsAppEnvironment = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + { + dst := &v.Jobs + src := firstPass.Jobs + *dst = make( + []*ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface, + len(src)) + for i, src := range src { + dst := &(*dst)[i] + if len(src) != 0 && string(src) != "null" { + *dst = new(ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface) + err = __unmarshalImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface( + src, *dst) + if err != nil { + return fmt.Errorf( + "unable to unmarshal ImportSQLProgressAppEnvironmentsAppEnvironment.Jobs: %w", err) + } + } + } + } + return nil +} + +type __premarshalImportSQLProgressAppEnvironmentsAppEnvironment struct { + Id *int64 `json:"id"` + + IsK8sResident *bool `json:"isK8sResident"` + + Launched *bool `json:"launched"` + + Jobs []json.RawMessage `json:"jobs"` + + ImportStatus *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus `json:"importStatus"` +} + +func (v *ImportSQLProgressAppEnvironmentsAppEnvironment) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *ImportSQLProgressAppEnvironmentsAppEnvironment) __premarshalJSON() (*__premarshalImportSQLProgressAppEnvironmentsAppEnvironment, error) { + var retval __premarshalImportSQLProgressAppEnvironmentsAppEnvironment + + retval.Id = v.Id + retval.IsK8sResident = v.IsK8sResident + retval.Launched = v.Launched + { + + dst := &retval.Jobs + src := v.Jobs + *dst = make( + []json.RawMessage, + len(src)) + for i, src := range src { + dst := &(*dst)[i] + if src != nil { + var err error + *dst, err = __marshalImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface( + src) + if err != nil { + return nil, fmt.Errorf( + "unable to marshal ImportSQLProgressAppEnvironmentsAppEnvironment.Jobs: %w", err) + } + } + } + } + retval.ImportStatus = v.ImportStatus + return &retval, nil +} + +// ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus includes the requested fields of the GraphQL type AppEnvironmentImportStatus. +// The GraphQL type's documentation follows. +// +// The current status of an environment import. +type ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus struct { + // Whether any database operation is currently in progress. + DbOperationInProgress *bool `json:"dbOperationInProgress"` + // Whether an import is currently in progress. + ImportInProgress *bool `json:"importInProgress"` + // Detailed progress information for the import. + Progress *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress `json:"progress"` +} + +// GetDbOperationInProgress returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus.DbOperationInProgress, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus) GetDbOperationInProgress() *bool { + return v.DbOperationInProgress +} + +// GetImportInProgress returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus.ImportInProgress, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus) GetImportInProgress() *bool { + return v.ImportInProgress +} + +// GetProgress returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus.Progress, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatus) GetProgress() *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress { + return v.Progress +} + +// ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress includes the requested fields of the GraphQL type AppEnvironmentStatusProgress. +// The GraphQL type's documentation follows. +// +// Progress details for an environment operation. +type ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress struct { + // When the operation started, as a Unix timestamp. + Started_at *int64 `json:"started_at"` + // The steps completed by the operation. + Steps []*ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep `json:"steps"` + // When the operation finished, as a Unix timestamp. + Finished_at *int64 `json:"finished_at"` +} + +// GetStarted_at returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress.Started_at, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress) GetStarted_at() *int64 { + return v.Started_at +} + +// GetSteps returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress.Steps, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress) GetSteps() []*ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep { + return v.Steps +} + +// GetFinished_at returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress.Finished_at, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgress) GetFinished_at() *int64 { + return v.Finished_at +} + +// ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep includes the requested fields of the GraphQL type AppEnvironmentStatusProgressStep. +// The GraphQL type's documentation follows. +// +// A single step in an environment progress flow. +type ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep struct { + // The display name of the step. + Name *string `json:"name"` + // When the step started, as a Unix timestamp. + Started_at *int64 `json:"started_at"` + // When the step finished, as a Unix timestamp. + Finished_at *int64 `json:"finished_at"` + // The result of the step. + Result *string `json:"result"` + // The output lines produced by the step. + Output []*string `json:"output"` +} + +// GetName returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep.Name, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep) GetName() *string { + return v.Name +} + +// GetStarted_at returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep.Started_at, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep) GetStarted_at() *int64 { + return v.Started_at +} + +// GetFinished_at returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep.Finished_at, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep) GetFinished_at() *int64 { + return v.Finished_at +} + +// GetResult returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep.Result, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep) GetResult() *string { + return v.Result +} + +// GetOutput returns ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep.Output, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentImportStatusProgressAppEnvironmentStatusProgressStepsAppEnvironmentStatusProgressStep) GetOutput() []*string { + return v.Output +} + +// ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob includes the requested fields of the GraphQL type Job. +// The GraphQL type's documentation follows. +// +// A background job. +type ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob struct { + Typename *string `json:"__typename"` + // The unique identifier for the job. + Id *int64 `json:"id"` + // The job type. + Type *string `json:"type"` + // When the job completed. + CompletedAt *string `json:"completedAt"` + // When the job was created. + CreatedAt *string `json:"createdAt"` + // The current progress of the job. + Progress *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress `json:"progress"` +} + +// GetTypename returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob.Typename, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob) GetTypename() *string { + return v.Typename +} + +// GetId returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob.Id, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob) GetId() *int64 { return v.Id } + +// GetType returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob.Type, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob) GetType() *string { return v.Type } + +// GetCompletedAt returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob.CompletedAt, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob) GetCompletedAt() *string { + return v.CompletedAt +} + +// GetCreatedAt returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob.CreatedAt, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob) GetCreatedAt() *string { + return v.CreatedAt +} + +// GetProgress returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob.Progress, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob) GetProgress() *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress { + return v.Progress +} + +// ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface includes the requested fields of the GraphQL interface JobInterface. +// +// ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface is implemented by the following types: +// ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob +// ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob +// The GraphQL type's documentation follows. +// +// Common fields shared by all job types. +type ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface interface { + implementsGraphQLInterfaceImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface() + // GetTypename returns the receiver's concrete GraphQL type-name (see interface doc for possible values). + GetTypename() *string + // GetId returns the interface-field "id" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The unique identifier for the job. + GetId() *int64 + // GetType returns the interface-field "type" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The job type. + GetType() *string + // GetCompletedAt returns the interface-field "completedAt" from its implementation. + // The GraphQL interface field's documentation follows. + // + // When the job completed. + GetCompletedAt() *string + // GetCreatedAt returns the interface-field "createdAt" from its implementation. + // The GraphQL interface field's documentation follows. + // + // When the job was created. + GetCreatedAt() *string + // GetProgress returns the interface-field "progress" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The current progress of the job. + GetProgress() *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress +} + +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob) implementsGraphQLInterfaceImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface() { +} +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) implementsGraphQLInterfaceImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface() { +} + +func __unmarshalImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface(b []byte, v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface) error { + if string(b) == "null" { + return nil + } + + var tn struct { + TypeName string `json:"__typename"` + } + err := json.Unmarshal(b, &tn) + if err != nil { + return err + } + + switch tn.TypeName { + case "Job": + *v = new(ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob) + return json.Unmarshal(b, *v) + case "PrimaryDomainSwitchJob": + *v = new(ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) + return json.Unmarshal(b, *v) + case "": + return fmt.Errorf( + "response was missing JobInterface.__typename") + default: + return fmt.Errorf( + `unexpected concrete type for ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface: "%v"`, tn.TypeName) + } +} + +func __marshalImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface(v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface) ([]byte, error) { + + var typename string + switch v := (*v).(type) { + case *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob: + typename = "Job" + + result := struct { + TypeName string `json:"__typename"` + *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJob + }{typename, v} + return json.Marshal(result) + case *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob: + typename = "PrimaryDomainSwitchJob" + + result := struct { + TypeName string `json:"__typename"` + *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob + }{typename, v} + return json.Marshal(result) + case nil: + return []byte("null"), nil + default: + return nil, fmt.Errorf( + `unexpected concrete type for ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterface: "%T"`, v) + } +} + +// ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress includes the requested fields of the GraphQL type JobProgress. +// The GraphQL type's documentation follows. +// +// Progress details for a job. +type ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress struct { + // The current status of the job. + Status *string `json:"status"` + // The individual progress steps for the job. + Steps []*ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep `json:"steps"` +} + +// GetStatus returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress.Status, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress) GetStatus() *string { + return v.Status +} + +// GetSteps returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress.Steps, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress) GetSteps() []*ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep { + return v.Steps +} + +// ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep includes the requested fields of the GraphQL type JobProgressStep. +// The GraphQL type's documentation follows. +// +// A single progress step within a job. +type ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep struct { + // The unique identifier for the step. + Id *string `json:"id"` + // The display name of the step. + Name *string `json:"name"` + // The current status of the step. + Status *string `json:"status"` +} + +// GetId returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Id, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetId() *string { + return v.Id +} + +// GetName returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Name, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetName() *string { + return v.Name +} + +// GetStatus returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Status, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetStatus() *string { + return v.Status +} + +// ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob includes the requested fields of the GraphQL type PrimaryDomainSwitchJob. +// The GraphQL type's documentation follows. +// +// A job that switches an environment's primary domain. +type ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob struct { + Typename *string `json:"__typename"` + // The unique identifier for the job. + Id *int64 `json:"id"` + // The job type. + Type *string `json:"type"` + // When the job completed. + CompletedAt *string `json:"completedAt"` + // When the job was created. + CreatedAt *string `json:"createdAt"` + // The current progress of the job. + Progress *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress `json:"progress"` +} + +// GetTypename returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Typename, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetTypename() *string { + return v.Typename +} + +// GetId returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Id, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetId() *int64 { + return v.Id +} + +// GetType returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Type, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetType() *string { + return v.Type +} + +// GetCompletedAt returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.CompletedAt, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetCompletedAt() *string { + return v.CompletedAt +} + +// GetCreatedAt returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.CreatedAt, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetCreatedAt() *string { + return v.CreatedAt +} + +// GetProgress returns ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Progress, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetProgress() *ImportSQLProgressAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress { + return v.Progress +} + +// ImportSQLProgressResponse is returned by ImportSQLProgress on success. +type ImportSQLProgressResponse struct { + // Retrieve a single application. + App *ImportSQLProgressApp `json:"app"` +} + +// GetApp returns ImportSQLProgressResponse.App, and is useful for accessing the field via an interface. +func (v *ImportSQLProgressResponse) GetApp() *ImportSQLProgressApp { return v.App } + +// Input for starting a live backup copy. +type LiveBackupCopyConfigInput struct { + // The live backup copy configuration payload. + Config *json.RawMessage `json:"config"` + // The environment ID. + EnvironmentId int64 `json:"environmentId"` + // The application ID. + Id int64 `json:"id"` +} + +// GetConfig returns LiveBackupCopyConfigInput.Config, and is useful for accessing the field via an interface. +func (v *LiveBackupCopyConfigInput) GetConfig() *json.RawMessage { return v.Config } + +// GetEnvironmentId returns LiveBackupCopyConfigInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *LiveBackupCopyConfigInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GetId returns LiveBackupCopyConfigInput.Id, and is useful for accessing the field via an interface. +func (v *LiveBackupCopyConfigInput) GetId() int64 { return v.Id } + +// MeMe includes the requested fields of the GraphQL type Me. +// The GraphQL type's documentation follows. +// +// The currently authenticated user. +type MeMe struct { + // The unique identifier for the current user. + Id *int64 `json:"id"` + // The display name for the current user. + DisplayName *string `json:"displayName"` + // Whether the current user currently has VIP access. + IsVIP *bool `json:"isVIP"` + // The organization roles assigned to the current user. + OrganizationRoles *MeMeOrganizationRolesUserOrganizationRoleList `json:"organizationRoles"` +} + +// GetId returns MeMe.Id, and is useful for accessing the field via an interface. +func (v *MeMe) GetId() *int64 { return v.Id } + +// GetDisplayName returns MeMe.DisplayName, and is useful for accessing the field via an interface. +func (v *MeMe) GetDisplayName() *string { return v.DisplayName } + +// GetIsVIP returns MeMe.IsVIP, and is useful for accessing the field via an interface. +func (v *MeMe) GetIsVIP() *bool { return v.IsVIP } + +// GetOrganizationRoles returns MeMe.OrganizationRoles, and is useful for accessing the field via an interface. +func (v *MeMe) GetOrganizationRoles() *MeMeOrganizationRolesUserOrganizationRoleList { + return v.OrganizationRoles +} + +// MeMeOrganizationRolesUserOrganizationRoleList includes the requested fields of the GraphQL type UserOrganizationRoleList. +// The GraphQL type's documentation follows. +// +// A paginated list of user organization roles. +type MeMeOrganizationRolesUserOrganizationRoleList struct { + // The role assignments returned in the current page. + Nodes []*MeMeOrganizationRolesUserOrganizationRoleListNodesUserOrganizationRole `json:"nodes"` +} + +// GetNodes returns MeMeOrganizationRolesUserOrganizationRoleList.Nodes, and is useful for accessing the field via an interface. +func (v *MeMeOrganizationRolesUserOrganizationRoleList) GetNodes() []*MeMeOrganizationRolesUserOrganizationRoleListNodesUserOrganizationRole { + return v.Nodes +} + +// MeMeOrganizationRolesUserOrganizationRoleListNodesUserOrganizationRole includes the requested fields of the GraphQL type UserOrganizationRole. +// The GraphQL type's documentation follows. +// +// An organization role assigned to a user. +type MeMeOrganizationRolesUserOrganizationRoleListNodesUserOrganizationRole struct { + // The organization ID the role applies to. + OrganizationId *int64 `json:"organizationId"` + // The role ID assigned to the user. + RoleId *OrgRoleId `json:"roleId"` +} + +// GetOrganizationId returns MeMeOrganizationRolesUserOrganizationRoleListNodesUserOrganizationRole.OrganizationId, and is useful for accessing the field via an interface. +func (v *MeMeOrganizationRolesUserOrganizationRoleListNodesUserOrganizationRole) GetOrganizationId() *int64 { + return v.OrganizationId +} + +// GetRoleId returns MeMeOrganizationRolesUserOrganizationRoleListNodesUserOrganizationRole.RoleId, and is useful for accessing the field via an interface. +func (v *MeMeOrganizationRolesUserOrganizationRoleListNodesUserOrganizationRole) GetRoleId() *OrgRoleId { + return v.RoleId +} + +// MeResponse is returned by Me on success. +type MeResponse struct { + // Retrieve the currently authenticated user. + Me *MeMe `json:"me"` +} + +// GetMe returns MeResponse.Me, and is useful for accessing the field via an interface. +func (v *MeResponse) GetMe() *MeMe { return v.Me } + +// MediaImportConfigMediaImportConfig includes the requested fields of the GraphQL type MediaImportConfig. +// The GraphQL type's documentation follows. +// +// Media Import Configuration +type MediaImportConfigMediaImportConfig struct { + // Allowed File Name Length + FileNameCharCount *int64 `json:"fileNameCharCount"` + // Allowed File Size Limit + FileSizeLimitInBytes *int64 `json:"fileSizeLimitInBytes"` + // Allowed File Types + AllowedFileTypes *json.RawMessage `json:"allowedFileTypes"` +} + +// GetFileNameCharCount returns MediaImportConfigMediaImportConfig.FileNameCharCount, and is useful for accessing the field via an interface. +func (v *MediaImportConfigMediaImportConfig) GetFileNameCharCount() *int64 { + return v.FileNameCharCount +} + +// GetFileSizeLimitInBytes returns MediaImportConfigMediaImportConfig.FileSizeLimitInBytes, and is useful for accessing the field via an interface. +func (v *MediaImportConfigMediaImportConfig) GetFileSizeLimitInBytes() *int64 { + return v.FileSizeLimitInBytes +} + +// GetAllowedFileTypes returns MediaImportConfigMediaImportConfig.AllowedFileTypes, and is useful for accessing the field via an interface. +func (v *MediaImportConfigMediaImportConfig) GetAllowedFileTypes() *json.RawMessage { + return v.AllowedFileTypes +} + +// MediaImportConfigResponse is returned by MediaImportConfig on success. +type MediaImportConfigResponse struct { + // Retrieve the current media import configuration. + MediaImportConfig *MediaImportConfigMediaImportConfig `json:"mediaImportConfig"` +} + +// GetMediaImportConfig returns MediaImportConfigResponse.MediaImportConfig, and is useful for accessing the field via an interface. +func (v *MediaImportConfigResponse) GetMediaImportConfig() *MediaImportConfigMediaImportConfig { + return v.MediaImportConfig +} + +// MediaImportProgressApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type MediaImportProgressApp struct { + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*MediaImportProgressAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetEnvironments returns MediaImportProgressApp.Environments, and is useful for accessing the field via an interface. +func (v *MediaImportProgressApp) GetEnvironments() []*MediaImportProgressAppEnvironmentsAppEnvironment { + return v.Environments +} + +// MediaImportProgressAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type MediaImportProgressAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The display name of the environment. + Name *string `json:"name"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // The repository name for the environment's codebase. + Repo *string `json:"repo"` + // The current media import status for the environment. + MediaImportStatus *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus `json:"mediaImportStatus"` +} + +// GetId returns MediaImportProgressAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetName returns MediaImportProgressAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironment) GetName() *string { return v.Name } + +// GetType returns MediaImportProgressAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironment) GetType() *string { return v.Type } + +// GetRepo returns MediaImportProgressAppEnvironmentsAppEnvironment.Repo, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironment) GetRepo() *string { return v.Repo } + +// GetMediaImportStatus returns MediaImportProgressAppEnvironmentsAppEnvironment.MediaImportStatus, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironment) GetMediaImportStatus() *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus { + return v.MediaImportStatus +} + +// MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus includes the requested fields of the GraphQL type AppEnvironmentMediaImportStatus. +// The GraphQL type's documentation follows. +// +// Current status of a Media Import +type MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus struct { + // Unique Identifier for a Media Import + ImportId *int64 `json:"importId"` + // Alias of environmentId + SiteId *int64 `json:"siteId"` + // The actual status of the Media Import + Status *string `json:"status"` + // Total number of media files that are to be import + FilesTotal *int64 `json:"filesTotal"` + // Total number of media files that were imported + FilesProcessed *int64 `json:"filesProcessed"` + // Media Import failure details + FailureDetails *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails `json:"failureDetails"` +} + +// GetImportId returns MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus.ImportId, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus) GetImportId() *int64 { + return v.ImportId +} + +// GetSiteId returns MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus.SiteId, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus) GetSiteId() *int64 { + return v.SiteId +} + +// GetStatus returns MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus.Status, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus) GetStatus() *string { + return v.Status +} + +// GetFilesTotal returns MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus.FilesTotal, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus) GetFilesTotal() *int64 { + return v.FilesTotal +} + +// GetFilesProcessed returns MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus.FilesProcessed, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus) GetFilesProcessed() *int64 { + return v.FilesProcessed +} + +// GetFailureDetails returns MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus.FailureDetails, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatus) GetFailureDetails() *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails { + return v.FailureDetails +} + +// MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails includes the requested fields of the GraphQL type AppEnvironmentMediaImportStatusFailureDetails. +// The GraphQL type's documentation follows. +// +// Media Import Failure details +type MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails struct { + // Status of the Media Import prior to failing + PreviousStatus *string `json:"previousStatus"` + // List of global errors per import + GlobalErrors []*string `json:"globalErrors"` + // URL to download the media import error log + FileErrorsUrl *string `json:"fileErrorsUrl"` +} + +// GetPreviousStatus returns MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails.PreviousStatus, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails) GetPreviousStatus() *string { + return v.PreviousStatus +} + +// GetGlobalErrors returns MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails.GlobalErrors, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails) GetGlobalErrors() []*string { + return v.GlobalErrors +} + +// GetFileErrorsUrl returns MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails.FileErrorsUrl, and is useful for accessing the field via an interface. +func (v *MediaImportProgressAppEnvironmentsAppEnvironmentMediaImportStatusFailureDetails) GetFileErrorsUrl() *string { + return v.FileErrorsUrl +} + +// MediaImportProgressResponse is returned by MediaImportProgress on success. +type MediaImportProgressResponse struct { + // Retrieve a single application. + App *MediaImportProgressApp `json:"app"` +} + +// GetApp returns MediaImportProgressResponse.App, and is useful for accessing the field via an interface. +func (v *MediaImportProgressResponse) GetApp() *MediaImportProgressApp { return v.App } + +// The available organization role IDs. +type OrgRoleId string + +const ( + // Organization administrator. + OrgRoleIdAdmin OrgRoleId = "admin" + // Organization member. + OrgRoleIdMember OrgRoleId = "member" + // Organization viewer. + OrgRoleIdViewer OrgRoleId = "viewer" +) + +var AllOrgRoleId = []OrgRoleId{ + OrgRoleIdAdmin, + OrgRoleIdMember, + OrgRoleIdViewer, +} + +// PhpMyAdminStatusApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type PhpMyAdminStatusApp struct { + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*PhpMyAdminStatusAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetEnvironments returns PhpMyAdminStatusApp.Environments, and is useful for accessing the field via an interface. +func (v *PhpMyAdminStatusApp) GetEnvironments() []*PhpMyAdminStatusAppEnvironmentsAppEnvironment { + return v.Environments +} + +// PhpMyAdminStatusAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type PhpMyAdminStatusAppEnvironmentsAppEnvironment struct { + // The phpMyAdmin availability status for the environment. + PhpMyAdminStatus *PhpMyAdminStatusAppEnvironmentsAppEnvironmentPhpMyAdminStatusPHPMyAdminStatus `json:"phpMyAdminStatus"` +} + +// GetPhpMyAdminStatus returns PhpMyAdminStatusAppEnvironmentsAppEnvironment.PhpMyAdminStatus, and is useful for accessing the field via an interface. +func (v *PhpMyAdminStatusAppEnvironmentsAppEnvironment) GetPhpMyAdminStatus() *PhpMyAdminStatusAppEnvironmentsAppEnvironmentPhpMyAdminStatusPHPMyAdminStatus { + return v.PhpMyAdminStatus +} + +// PhpMyAdminStatusAppEnvironmentsAppEnvironmentPhpMyAdminStatusPHPMyAdminStatus includes the requested fields of the GraphQL type PHPMyAdminStatus. +// The GraphQL type's documentation follows. +// +// The phpMyAdmin status for an environment. +type PhpMyAdminStatusAppEnvironmentsAppEnvironmentPhpMyAdminStatusPHPMyAdminStatus struct { + // The current phpMyAdmin status value. + Status *string `json:"status"` +} + +// GetStatus returns PhpMyAdminStatusAppEnvironmentsAppEnvironmentPhpMyAdminStatusPHPMyAdminStatus.Status, and is useful for accessing the field via an interface. +func (v *PhpMyAdminStatusAppEnvironmentsAppEnvironmentPhpMyAdminStatusPHPMyAdminStatus) GetStatus() *string { + return v.Status +} + +// PhpMyAdminStatusResponse is returned by PhpMyAdminStatus on success. +type PhpMyAdminStatusResponse struct { + // Retrieve a single application. + App *PhpMyAdminStatusApp `json:"app"` +} + +// GetApp returns PhpMyAdminStatusResponse.App, and is useful for accessing the field via an interface. +func (v *PhpMyAdminStatusResponse) GetApp() *PhpMyAdminStatusApp { return v.App } + +// Input for purging page cache entries. +type PurgePageCacheInput struct { + // The application ID whose cache should be purged. + AppId int64 `json:"appId"` + // The environment ID whose cache should be purged. + EnvironmentId int64 `json:"environmentId"` + // The URLs to purge from page cache. + Urls []string `json:"urls"` +} + +// GetAppId returns PurgePageCacheInput.AppId, and is useful for accessing the field via an interface. +func (v *PurgePageCacheInput) GetAppId() int64 { return v.AppId } + +// GetEnvironmentId returns PurgePageCacheInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *PurgePageCacheInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GetUrls returns PurgePageCacheInput.Urls, and is useful for accessing the field via an interface. +func (v *PurgePageCacheInput) GetUrls() []string { return v.Urls } + +// PurgePageCachePurgePageCachePurgePageCachePayload includes the requested fields of the GraphQL type PurgePageCachePayload. +// The GraphQL type's documentation follows. +// +// The result of a page cache purge request. +type PurgePageCachePurgePageCachePurgePageCachePayload struct { + // Whether the purge request succeeded. + Success bool `json:"success"` + // The URLs that were targeted for purge. + Urls []string `json:"urls"` +} + +// GetSuccess returns PurgePageCachePurgePageCachePurgePageCachePayload.Success, and is useful for accessing the field via an interface. +func (v *PurgePageCachePurgePageCachePurgePageCachePayload) GetSuccess() bool { return v.Success } + +// GetUrls returns PurgePageCachePurgePageCachePurgePageCachePayload.Urls, and is useful for accessing the field via an interface. +func (v *PurgePageCachePurgePageCachePurgePageCachePayload) GetUrls() []string { return v.Urls } + +// PurgePageCacheResponse is returned by PurgePageCache on success. +type PurgePageCacheResponse struct { + // Purge page cache object(s) + PurgePageCache *PurgePageCachePurgePageCachePurgePageCachePayload `json:"purgePageCache"` +} + +// GetPurgePageCache returns PurgePageCacheResponse.PurgePageCache, and is useful for accessing the field via an interface. +func (v *PurgePageCacheResponse) GetPurgePageCache() *PurgePageCachePurgePageCachePurgePageCachePayload { + return v.PurgePageCache +} + +// A request header to include in a cache debug request. +type RequestHeader struct { + // The header name. + Name string `json:"name"` + // The header value. + Value string `json:"value"` +} + +// GetName returns RequestHeader.Name, and is useful for accessing the field via an interface. +func (v *RequestHeader) GetName() string { return v.Name } + +// GetValue returns RequestHeader.Value, and is useful for accessing the field via an interface. +func (v *RequestHeader) GetValue() string { return v.Value } + +// ResolveAppByIDApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type ResolveAppByIDApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The application platform type, such as WordPress or Node.js. + Type *string `json:"type"` + // The internal numeric identifier for the application type. + TypeId *int64 `json:"typeId"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*ResolveAppByIDAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns ResolveAppByIDApp.Id, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDApp) GetId() *int64 { return v.Id } + +// GetName returns ResolveAppByIDApp.Name, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDApp) GetName() *string { return v.Name } + +// GetType returns ResolveAppByIDApp.Type, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDApp) GetType() *string { return v.Type } + +// GetTypeId returns ResolveAppByIDApp.TypeId, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDApp) GetTypeId() *int64 { return v.TypeId } + +// GetEnvironments returns ResolveAppByIDApp.Environments, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDApp) GetEnvironments() []*ResolveAppByIDAppEnvironmentsAppEnvironment { + return v.Environments +} + +// ResolveAppByIDAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type ResolveAppByIDAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The application ID that owns the environment. + AppId *int64 `json:"appId"` + // The display name of the environment. + Name *string `json:"name"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // The unique label for the environment. + UniqueLabel *string `json:"uniqueLabel"` + // The default domain assigned to the environment. + DefaultDomain *string `json:"defaultDomain"` + // Whether the environment is a multisite install. + IsMultisite *bool `json:"isMultisite"` +} + +// GetId returns ResolveAppByIDAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetAppId returns ResolveAppByIDAppEnvironmentsAppEnvironment.AppId, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDAppEnvironmentsAppEnvironment) GetAppId() *int64 { return v.AppId } + +// GetName returns ResolveAppByIDAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDAppEnvironmentsAppEnvironment) GetName() *string { return v.Name } + +// GetType returns ResolveAppByIDAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDAppEnvironmentsAppEnvironment) GetType() *string { return v.Type } + +// GetUniqueLabel returns ResolveAppByIDAppEnvironmentsAppEnvironment.UniqueLabel, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDAppEnvironmentsAppEnvironment) GetUniqueLabel() *string { return v.UniqueLabel } + +// GetDefaultDomain returns ResolveAppByIDAppEnvironmentsAppEnvironment.DefaultDomain, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDAppEnvironmentsAppEnvironment) GetDefaultDomain() *string { + return v.DefaultDomain +} + +// GetIsMultisite returns ResolveAppByIDAppEnvironmentsAppEnvironment.IsMultisite, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDAppEnvironmentsAppEnvironment) GetIsMultisite() *bool { return v.IsMultisite } + +// ResolveAppByIDResponse is returned by ResolveAppByID on success. +type ResolveAppByIDResponse struct { + // Retrieve a single application. + App *ResolveAppByIDApp `json:"app"` +} + +// GetApp returns ResolveAppByIDResponse.App, and is useful for accessing the field via an interface. +func (v *ResolveAppByIDResponse) GetApp() *ResolveAppByIDApp { return v.App } + +// ResolveAppByNameAppsAppList includes the requested fields of the GraphQL type AppList. +// The GraphQL type's documentation follows. +// +// A paginated list of applications. +type ResolveAppByNameAppsAppList struct { + // A legacy alias for `nodes`. + Edges []*ResolveAppByNameAppsAppListEdgesApp `json:"edges"` +} + +// GetEdges returns ResolveAppByNameAppsAppList.Edges, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppList) GetEdges() []*ResolveAppByNameAppsAppListEdgesApp { + return v.Edges +} + +// ResolveAppByNameAppsAppListEdgesApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type ResolveAppByNameAppsAppListEdgesApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The application platform type, such as WordPress or Node.js. + Type *string `json:"type"` + // The internal numeric identifier for the application type. + TypeId *int64 `json:"typeId"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns ResolveAppByNameAppsAppListEdgesApp.Id, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesApp) GetId() *int64 { return v.Id } + +// GetName returns ResolveAppByNameAppsAppListEdgesApp.Name, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesApp) GetName() *string { return v.Name } + +// GetType returns ResolveAppByNameAppsAppListEdgesApp.Type, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesApp) GetType() *string { return v.Type } + +// GetTypeId returns ResolveAppByNameAppsAppListEdgesApp.TypeId, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesApp) GetTypeId() *int64 { return v.TypeId } + +// GetEnvironments returns ResolveAppByNameAppsAppListEdgesApp.Environments, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesApp) GetEnvironments() []*ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment { + return v.Environments +} + +// ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The application ID that owns the environment. + AppId *int64 `json:"appId"` + // The display name of the environment. + Name *string `json:"name"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // The unique label for the environment. + UniqueLabel *string `json:"uniqueLabel"` + // The default domain assigned to the environment. + DefaultDomain *string `json:"defaultDomain"` + // Whether the environment is a multisite install. + IsMultisite *bool `json:"isMultisite"` +} + +// GetId returns ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetAppId returns ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.AppId, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetAppId() *int64 { + return v.AppId +} + +// GetName returns ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetName() *string { + return v.Name +} + +// GetType returns ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetType() *string { + return v.Type +} + +// GetUniqueLabel returns ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.UniqueLabel, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetUniqueLabel() *string { + return v.UniqueLabel +} + +// GetDefaultDomain returns ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.DefaultDomain, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetDefaultDomain() *string { + return v.DefaultDomain +} + +// GetIsMultisite returns ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment.IsMultisite, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment) GetIsMultisite() *bool { + return v.IsMultisite +} + +// ResolveAppByNameResponse is returned by ResolveAppByName on success. +type ResolveAppByNameResponse struct { + // Retrieve a paginated list of applications. + Apps *ResolveAppByNameAppsAppList `json:"apps"` +} + +// GetApps returns ResolveAppByNameResponse.Apps, and is useful for accessing the field via an interface. +func (v *ResolveAppByNameResponse) GetApps() *ResolveAppByNameAppsAppList { return v.Apps } + +// SoftwareNode includes the GraphQL fields of AppEnvironmentSoftwareSettingsSoftware requested by the fragment SoftwareNode. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type SoftwareNode struct { + // The display name of the software. + Name string `json:"name"` + // The internal slug of the software. + Slug string `json:"slug"` + // Whether the software version is pinned. + Pinned bool `json:"pinned"` + // The currently selected version. + Current *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` + // The available version options. + Options []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion `json:"options"` +} + +// GetName returns SoftwareNode.Name, and is useful for accessing the field via an interface. +func (v *SoftwareNode) GetName() string { return v.Name } + +// GetSlug returns SoftwareNode.Slug, and is useful for accessing the field via an interface. +func (v *SoftwareNode) GetSlug() string { return v.Slug } + +// GetPinned returns SoftwareNode.Pinned, and is useful for accessing the field via an interface. +func (v *SoftwareNode) GetPinned() bool { return v.Pinned } + +// GetCurrent returns SoftwareNode.Current, and is useful for accessing the field via an interface. +func (v *SoftwareNode) GetCurrent() *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion { + return v.Current +} + +// GetOptions returns SoftwareNode.Options, and is useful for accessing the field via an interface. +func (v *SoftwareNode) GetOptions() []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion { + return v.Options +} + +// SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsVersion. +// The GraphQL type's documentation follows. +// +// A software version option available for an environment. +type SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion struct { + // The version identifier. + Version string `json:"version"` + // Whether this is the default version. + Default bool `json:"default"` + // Whether this version is deprecated. + Deprecated bool `json:"deprecated"` + // Whether this version is unstable. + Unstable bool `json:"unstable"` + // Whether this version is compatible with the environment. + Compatible bool `json:"compatible"` + // The latest available release for this software. + LatestRelease string `json:"latestRelease"` + // Whether this version is private. + Private bool `json:"private"` +} + +// GetVersion returns SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion.Version, and is useful for accessing the field via an interface. +func (v *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion) GetVersion() string { + return v.Version +} + +// GetDefault returns SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion.Default, and is useful for accessing the field via an interface. +func (v *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion) GetDefault() bool { + return v.Default +} + +// GetDeprecated returns SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion.Deprecated, and is useful for accessing the field via an interface. +func (v *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion) GetDeprecated() bool { + return v.Deprecated +} + +// GetUnstable returns SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion.Unstable, and is useful for accessing the field via an interface. +func (v *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion) GetUnstable() bool { + return v.Unstable +} + +// GetCompatible returns SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion.Compatible, and is useful for accessing the field via an interface. +func (v *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion) GetCompatible() bool { + return v.Compatible +} + +// GetLatestRelease returns SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion.LatestRelease, and is useful for accessing the field via an interface. +func (v *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion) GetLatestRelease() string { + return v.LatestRelease +} + +// GetPrivate returns SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion.Private, and is useful for accessing the field via an interface. +func (v *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion) GetPrivate() bool { + return v.Private +} + +// SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsVersion. +// The GraphQL type's documentation follows. +// +// A software version option available for an environment. +type SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion struct { + // The version identifier. + Version string `json:"version"` + // Whether this is the default version. + Default bool `json:"default"` + // Whether this version is deprecated. + Deprecated bool `json:"deprecated"` + // Whether this version is unstable. + Unstable bool `json:"unstable"` + // Whether this version is compatible with the environment. + Compatible bool `json:"compatible"` + // The latest available release for this software. + LatestRelease string `json:"latestRelease"` + // Whether this version is private. + Private bool `json:"private"` +} + +// GetVersion returns SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion.Version, and is useful for accessing the field via an interface. +func (v *SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion) GetVersion() string { + return v.Version +} + +// GetDefault returns SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion.Default, and is useful for accessing the field via an interface. +func (v *SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion) GetDefault() bool { + return v.Default +} + +// GetDeprecated returns SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion.Deprecated, and is useful for accessing the field via an interface. +func (v *SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion) GetDeprecated() bool { + return v.Deprecated +} + +// GetUnstable returns SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion.Unstable, and is useful for accessing the field via an interface. +func (v *SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion) GetUnstable() bool { + return v.Unstable +} + +// GetCompatible returns SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion.Compatible, and is useful for accessing the field via an interface. +func (v *SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion) GetCompatible() bool { + return v.Compatible +} + +// GetLatestRelease returns SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion.LatestRelease, and is useful for accessing the field via an interface. +func (v *SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion) GetLatestRelease() string { + return v.LatestRelease +} + +// GetPrivate returns SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion.Private, and is useful for accessing the field via an interface. +func (v *SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion) GetPrivate() bool { + return v.Private +} + +// SoftwareSettingsApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type SoftwareSettingsApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The internal numeric identifier for the application type. + TypeId *int64 `json:"typeId"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*SoftwareSettingsAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns SoftwareSettingsApp.Id, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsApp) GetId() *int64 { return v.Id } + +// GetName returns SoftwareSettingsApp.Name, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsApp) GetName() *string { return v.Name } + +// GetTypeId returns SoftwareSettingsApp.TypeId, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsApp) GetTypeId() *int64 { return v.TypeId } + +// GetEnvironments returns SoftwareSettingsApp.Environments, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsApp) GetEnvironments() []*SoftwareSettingsAppEnvironmentsAppEnvironment { + return v.Environments +} + +// SoftwareSettingsAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type SoftwareSettingsAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The application ID that owns the environment. + AppId *int64 `json:"appId"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // The display name of the environment. + Name *string `json:"name"` + // The software settings for the environment. + SoftwareSettings *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings `json:"softwareSettings"` +} + +// GetId returns SoftwareSettingsAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetAppId returns SoftwareSettingsAppEnvironmentsAppEnvironment.AppId, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironment) GetAppId() *int64 { return v.AppId } + +// GetType returns SoftwareSettingsAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironment) GetType() *string { return v.Type } + +// GetName returns SoftwareSettingsAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironment) GetName() *string { return v.Name } + +// GetSoftwareSettings returns SoftwareSettingsAppEnvironmentsAppEnvironment.SoftwareSettings, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironment) GetSoftwareSettings() *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings { + return v.SoftwareSettings +} + +// SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettings. +// The GraphQL type's documentation follows. +// +// Available software settings for an application environment. +type SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings struct { + // The WordPress software settings. + Wordpress *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware `json:"wordpress"` + // The PHP software settings. + Php *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware `json:"php"` + // The mu-plugins software settings. + Muplugins *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware `json:"muplugins"` + // The Node.js software settings. + Nodejs *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware `json:"nodejs"` +} + +// GetWordpress returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings.Wordpress, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings) GetWordpress() *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware { + return v.Wordpress +} + +// GetPhp returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings.Php, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings) GetPhp() *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware { + return v.Php +} + +// GetMuplugins returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings.Muplugins, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings) GetMuplugins() *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware { + return v.Muplugins +} + +// GetNodejs returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings.Nodejs, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettings) GetNodejs() *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware { + return v.Nodejs +} + +// SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware struct { + SoftwareNode `json:"-"` +} + +// GetName returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Name, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetName() string { + return v.SoftwareNode.Name +} + +// GetSlug returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Slug, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetSlug() string { + return v.SoftwareNode.Slug +} + +// GetPinned returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Pinned, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetPinned() bool { + return v.SoftwareNode.Pinned +} + +// GetCurrent returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Current +} + +// GetOptions returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Options, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetOptions() []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Options +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware + graphql.NoUnmarshalJSON + } + firstPass.SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.SoftwareNode) + if err != nil { + return err + } + return nil +} + +type __premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware struct { + Name string `json:"name"` + + Slug string `json:"slug"` + + Pinned bool `json:"pinned"` + + Current *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` + + Options []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion `json:"options"` +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) __premarshalJSON() (*__premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware, error) { + var retval __premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware + + retval.Name = v.SoftwareNode.Name + retval.Slug = v.SoftwareNode.Slug + retval.Pinned = v.SoftwareNode.Pinned + retval.Current = v.SoftwareNode.Current + retval.Options = v.SoftwareNode.Options + return &retval, nil +} + +// SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware struct { + SoftwareNode `json:"-"` +} + +// GetName returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Name, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetName() string { + return v.SoftwareNode.Name +} + +// GetSlug returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Slug, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetSlug() string { + return v.SoftwareNode.Slug +} + +// GetPinned returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Pinned, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetPinned() bool { + return v.SoftwareNode.Pinned +} + +// GetCurrent returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Current +} + +// GetOptions returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Options, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetOptions() []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Options +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware + graphql.NoUnmarshalJSON + } + firstPass.SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.SoftwareNode) + if err != nil { + return err + } + return nil +} + +type __premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware struct { + Name string `json:"name"` + + Slug string `json:"slug"` + + Pinned bool `json:"pinned"` + + Current *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` + + Options []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion `json:"options"` +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) __premarshalJSON() (*__premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware, error) { + var retval __premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware + + retval.Name = v.SoftwareNode.Name + retval.Slug = v.SoftwareNode.Slug + retval.Pinned = v.SoftwareNode.Pinned + retval.Current = v.SoftwareNode.Current + retval.Options = v.SoftwareNode.Options + return &retval, nil +} + +// SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware struct { + SoftwareNode `json:"-"` +} + +// GetName returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Name, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetName() string { + return v.SoftwareNode.Name +} + +// GetSlug returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Slug, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetSlug() string { + return v.SoftwareNode.Slug +} + +// GetPinned returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Pinned, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetPinned() bool { + return v.SoftwareNode.Pinned +} + +// GetCurrent returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Current +} + +// GetOptions returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Options, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetOptions() []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Options +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware + graphql.NoUnmarshalJSON + } + firstPass.SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.SoftwareNode) + if err != nil { + return err + } + return nil +} + +type __premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware struct { + Name string `json:"name"` + + Slug string `json:"slug"` + + Pinned bool `json:"pinned"` + + Current *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` + + Options []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion `json:"options"` +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) __premarshalJSON() (*__premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware, error) { + var retval __premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware + + retval.Name = v.SoftwareNode.Name + retval.Slug = v.SoftwareNode.Slug + retval.Pinned = v.SoftwareNode.Pinned + retval.Current = v.SoftwareNode.Current + retval.Options = v.SoftwareNode.Options + return &retval, nil +} + +// SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware struct { + SoftwareNode `json:"-"` +} + +// GetName returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Name, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetName() string { + return v.SoftwareNode.Name +} + +// GetSlug returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Slug, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetSlug() string { + return v.SoftwareNode.Slug +} + +// GetPinned returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Pinned, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetPinned() bool { + return v.SoftwareNode.Pinned +} + +// GetCurrent returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Current +} + +// GetOptions returns SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Options, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetOptions() []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Options +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware + graphql.NoUnmarshalJSON + } + firstPass.SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.SoftwareNode) + if err != nil { + return err + } + return nil +} + +type __premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware struct { + Name string `json:"name"` + + Slug string `json:"slug"` + + Pinned bool `json:"pinned"` + + Current *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` + + Options []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion `json:"options"` +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *SoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) __premarshalJSON() (*__premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware, error) { + var retval __premarshalSoftwareSettingsAppEnvironmentsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware + + retval.Name = v.SoftwareNode.Name + retval.Slug = v.SoftwareNode.Slug + retval.Pinned = v.SoftwareNode.Pinned + retval.Current = v.SoftwareNode.Current + retval.Options = v.SoftwareNode.Options + return &retval, nil +} + +// SoftwareSettingsResponse is returned by SoftwareSettings on success. +type SoftwareSettingsResponse struct { + // Retrieve a single application. + App *SoftwareSettingsApp `json:"app"` +} + +// GetApp returns SoftwareSettingsResponse.App, and is useful for accessing the field via an interface. +func (v *SoftwareSettingsResponse) GetApp() *SoftwareSettingsApp { return v.App } + +// SoftwareUpdateJobApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type SoftwareUpdateJobApp struct { + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*SoftwareUpdateJobAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetEnvironments returns SoftwareUpdateJobApp.Environments, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobApp) GetEnvironments() []*SoftwareUpdateJobAppEnvironmentsAppEnvironment { + return v.Environments +} + +// SoftwareUpdateJobAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type SoftwareUpdateJobAppEnvironmentsAppEnvironment struct { + // Jobs running on or related to the environment. + Jobs []*SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface `json:"-"` +} + +// GetJobs returns SoftwareUpdateJobAppEnvironmentsAppEnvironment.Jobs, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironment) GetJobs() []*SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface { + return v.Jobs +} + +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironment) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *SoftwareUpdateJobAppEnvironmentsAppEnvironment + Jobs []json.RawMessage `json:"jobs"` + graphql.NoUnmarshalJSON + } + firstPass.SoftwareUpdateJobAppEnvironmentsAppEnvironment = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + { + dst := &v.Jobs + src := firstPass.Jobs + *dst = make( + []*SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface, + len(src)) + for i, src := range src { + dst := &(*dst)[i] + if len(src) != 0 && string(src) != "null" { + *dst = new(SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface) + err = __unmarshalSoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface( + src, *dst) + if err != nil { + return fmt.Errorf( + "unable to unmarshal SoftwareUpdateJobAppEnvironmentsAppEnvironment.Jobs: %w", err) + } + } + } + } + return nil +} + +type __premarshalSoftwareUpdateJobAppEnvironmentsAppEnvironment struct { + Jobs []json.RawMessage `json:"jobs"` +} + +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironment) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironment) __premarshalJSON() (*__premarshalSoftwareUpdateJobAppEnvironmentsAppEnvironment, error) { + var retval __premarshalSoftwareUpdateJobAppEnvironmentsAppEnvironment + + { + + dst := &retval.Jobs + src := v.Jobs + *dst = make( + []json.RawMessage, + len(src)) + for i, src := range src { + dst := &(*dst)[i] + if src != nil { + var err error + *dst, err = __marshalSoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface( + src) + if err != nil { + return nil, fmt.Errorf( + "unable to marshal SoftwareUpdateJobAppEnvironmentsAppEnvironment.Jobs: %w", err) + } + } + } + } + return &retval, nil +} + +// SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob includes the requested fields of the GraphQL type Job. +// The GraphQL type's documentation follows. +// +// A background job. +type SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob struct { + Typename *string `json:"__typename"` + // The job type. + Type *string `json:"type"` + // When the job completed. + CompletedAt *string `json:"completedAt"` + // When the job was created. + CreatedAt *string `json:"createdAt"` + // Whether the job currently holds an in-progress lock. + InProgressLock *bool `json:"inProgressLock"` + // The current progress of the job. + Progress *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress `json:"progress"` +} + +// GetTypename returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob.Typename, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob) GetTypename() *string { + return v.Typename +} + +// GetType returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob.Type, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob) GetType() *string { return v.Type } + +// GetCompletedAt returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob.CompletedAt, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob) GetCompletedAt() *string { + return v.CompletedAt +} + +// GetCreatedAt returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob.CreatedAt, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob) GetCreatedAt() *string { + return v.CreatedAt +} + +// GetInProgressLock returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob.InProgressLock, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob) GetInProgressLock() *bool { + return v.InProgressLock +} + +// GetProgress returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob.Progress, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob) GetProgress() *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress { + return v.Progress +} + +// SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface includes the requested fields of the GraphQL interface JobInterface. +// +// SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface is implemented by the following types: +// SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob +// SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob +// The GraphQL type's documentation follows. +// +// Common fields shared by all job types. +type SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface interface { + implementsGraphQLInterfaceSoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface() + // GetTypename returns the receiver's concrete GraphQL type-name (see interface doc for possible values). + GetTypename() *string + // GetType returns the interface-field "type" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The job type. + GetType() *string + // GetCompletedAt returns the interface-field "completedAt" from its implementation. + // The GraphQL interface field's documentation follows. + // + // When the job completed. + GetCompletedAt() *string + // GetCreatedAt returns the interface-field "createdAt" from its implementation. + // The GraphQL interface field's documentation follows. + // + // When the job was created. + GetCreatedAt() *string + // GetInProgressLock returns the interface-field "inProgressLock" from its implementation. + // The GraphQL interface field's documentation follows. + // + // Whether the job currently holds an in-progress lock. + GetInProgressLock() *bool + // GetProgress returns the interface-field "progress" from its implementation. + // The GraphQL interface field's documentation follows. + // + // The current progress of the job. + GetProgress() *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress +} + +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob) implementsGraphQLInterfaceSoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface() { +} +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) implementsGraphQLInterfaceSoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface() { +} + +func __unmarshalSoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface(b []byte, v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface) error { + if string(b) == "null" { + return nil + } + + var tn struct { + TypeName string `json:"__typename"` + } + err := json.Unmarshal(b, &tn) + if err != nil { + return err + } + + switch tn.TypeName { + case "Job": + *v = new(SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob) + return json.Unmarshal(b, *v) + case "PrimaryDomainSwitchJob": + *v = new(SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) + return json.Unmarshal(b, *v) + case "": + return fmt.Errorf( + "response was missing JobInterface.__typename") + default: + return fmt.Errorf( + `unexpected concrete type for SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface: "%v"`, tn.TypeName) + } +} + +func __marshalSoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface(v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface) ([]byte, error) { + + var typename string + switch v := (*v).(type) { + case *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob: + typename = "Job" + + result := struct { + TypeName string `json:"__typename"` + *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJob + }{typename, v} + return json.Marshal(result) + case *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob: + typename = "PrimaryDomainSwitchJob" + + result := struct { + TypeName string `json:"__typename"` + *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob + }{typename, v} + return json.Marshal(result) + case nil: + return []byte("null"), nil + default: + return nil, fmt.Errorf( + `unexpected concrete type for SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterface: "%T"`, v) + } +} + +// SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress includes the requested fields of the GraphQL type JobProgress. +// The GraphQL type's documentation follows. +// +// Progress details for a job. +type SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress struct { + // The current status of the job. + Status *string `json:"status"` + // The individual progress steps for the job. + Steps []*SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep `json:"steps"` +} + +// GetStatus returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress.Status, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress) GetStatus() *string { + return v.Status +} + +// GetSteps returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress.Steps, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress) GetSteps() []*SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep { + return v.Steps +} + +// SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep includes the requested fields of the GraphQL type JobProgressStep. +// The GraphQL type's documentation follows. +// +// A single progress step within a job. +type SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep struct { + // The step key. + Step *string `json:"step"` + // The display name of the step. + Name *string `json:"name"` + // The current status of the step. + Status *string `json:"status"` +} + +// GetStep returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Step, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetStep() *string { + return v.Step +} + +// GetName returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Name, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetName() *string { + return v.Name +} + +// GetStatus returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep.Status, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgressStepsJobProgressStep) GetStatus() *string { + return v.Status +} + +// SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob includes the requested fields of the GraphQL type PrimaryDomainSwitchJob. +// The GraphQL type's documentation follows. +// +// A job that switches an environment's primary domain. +type SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob struct { + Typename *string `json:"__typename"` + // The job type. + Type *string `json:"type"` + // When the job completed. + CompletedAt *string `json:"completedAt"` + // When the job was created. + CreatedAt *string `json:"createdAt"` + // Whether the job currently holds an in-progress lock. + InProgressLock *bool `json:"inProgressLock"` + // The current progress of the job. + Progress *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress `json:"progress"` +} + +// GetTypename returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Typename, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetTypename() *string { + return v.Typename +} + +// GetType returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Type, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetType() *string { + return v.Type +} + +// GetCompletedAt returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.CompletedAt, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetCompletedAt() *string { + return v.CompletedAt +} + +// GetCreatedAt returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.CreatedAt, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetCreatedAt() *string { + return v.CreatedAt +} + +// GetInProgressLock returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.InProgressLock, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetInProgressLock() *bool { + return v.InProgressLock +} + +// GetProgress returns SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob.Progress, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsPrimaryDomainSwitchJob) GetProgress() *SoftwareUpdateJobAppEnvironmentsAppEnvironmentJobsJobInterfaceProgressJobProgress { + return v.Progress +} + +// SoftwareUpdateJobResponse is returned by SoftwareUpdateJob on success. +type SoftwareUpdateJobResponse struct { + // Retrieve a single application. + App *SoftwareUpdateJobApp `json:"app"` +} + +// GetApp returns SoftwareUpdateJobResponse.App, and is useful for accessing the field via an interface. +func (v *SoftwareUpdateJobResponse) GetApp() *SoftwareUpdateJobApp { return v.App } + +// StartCustomDeployResponse is returned by StartCustomDeploy on success. +type StartCustomDeployResponse struct { + // Start a custom deploy on an environment. + StartCustomDeploy *StartCustomDeployStartCustomDeployAppEnvironmentCustomDeployPayload `json:"startCustomDeploy"` +} + +// GetStartCustomDeploy returns StartCustomDeployResponse.StartCustomDeploy, and is useful for accessing the field via an interface. +func (v *StartCustomDeployResponse) GetStartCustomDeploy() *StartCustomDeployStartCustomDeployAppEnvironmentCustomDeployPayload { + return v.StartCustomDeploy +} + +// StartCustomDeployStartCustomDeployAppEnvironmentCustomDeployPayload includes the requested fields of the GraphQL type AppEnvironmentCustomDeployPayload. +// The GraphQL type's documentation follows. +// +// The result of starting a custom deploy. +type StartCustomDeployStartCustomDeployAppEnvironmentCustomDeployPayload struct { + // Whether the custom deploy request succeeded. + Success *bool `json:"success"` + // A human-readable message about the deploy request. + Message *string `json:"message"` +} + +// GetSuccess returns StartCustomDeployStartCustomDeployAppEnvironmentCustomDeployPayload.Success, and is useful for accessing the field via an interface. +func (v *StartCustomDeployStartCustomDeployAppEnvironmentCustomDeployPayload) GetSuccess() *bool { + return v.Success +} + +// GetMessage returns StartCustomDeployStartCustomDeployAppEnvironmentCustomDeployPayload.Message, and is useful for accessing the field via an interface. +func (v *StartCustomDeployStartCustomDeployAppEnvironmentCustomDeployPayload) GetMessage() *string { + return v.Message +} + +// StartImportResponse is returned by StartImport on success. +type StartImportResponse struct { + // Start importing data into an environment. + StartImport *StartImportStartImportAppEnvironmentImportPayload `json:"startImport"` +} + +// GetStartImport returns StartImportResponse.StartImport, and is useful for accessing the field via an interface. +func (v *StartImportResponse) GetStartImport() *StartImportStartImportAppEnvironmentImportPayload { + return v.StartImport +} + +// StartImportStartImportAppEnvironmentImportPayload includes the requested fields of the GraphQL type AppEnvironmentImportPayload. +// The GraphQL type's documentation follows. +// +// The result of starting an environment import. +type StartImportStartImportAppEnvironmentImportPayload struct { + // The application that owns the environment. + App *StartImportStartImportAppEnvironmentImportPayloadApp `json:"app"` + // A human-readable result message. + Message *string `json:"message"` + // Whether the operation succeeded. + Success *bool `json:"success"` +} + +// GetApp returns StartImportStartImportAppEnvironmentImportPayload.App, and is useful for accessing the field via an interface. +func (v *StartImportStartImportAppEnvironmentImportPayload) GetApp() *StartImportStartImportAppEnvironmentImportPayloadApp { + return v.App +} + +// GetMessage returns StartImportStartImportAppEnvironmentImportPayload.Message, and is useful for accessing the field via an interface. +func (v *StartImportStartImportAppEnvironmentImportPayload) GetMessage() *string { return v.Message } + +// GetSuccess returns StartImportStartImportAppEnvironmentImportPayload.Success, and is useful for accessing the field via an interface. +func (v *StartImportStartImportAppEnvironmentImportPayload) GetSuccess() *bool { return v.Success } + +// StartImportStartImportAppEnvironmentImportPayloadApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type StartImportStartImportAppEnvironmentImportPayloadApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` +} + +// GetId returns StartImportStartImportAppEnvironmentImportPayloadApp.Id, and is useful for accessing the field via an interface. +func (v *StartImportStartImportAppEnvironmentImportPayloadApp) GetId() *int64 { return v.Id } + +// GetName returns StartImportStartImportAppEnvironmentImportPayloadApp.Name, and is useful for accessing the field via an interface. +func (v *StartImportStartImportAppEnvironmentImportPayloadApp) GetName() *string { return v.Name } + +// StartLiveBackupCopyResponse is returned by StartLiveBackupCopy on success. +type StartLiveBackupCopyResponse struct { + // Start a live backup copy. + StartLiveBackupCopy *StartLiveBackupCopyStartLiveBackupCopyAppEnvironmentStartLiveBackupCopyPayload `json:"startLiveBackupCopy"` +} + +// GetStartLiveBackupCopy returns StartLiveBackupCopyResponse.StartLiveBackupCopy, and is useful for accessing the field via an interface. +func (v *StartLiveBackupCopyResponse) GetStartLiveBackupCopy() *StartLiveBackupCopyStartLiveBackupCopyAppEnvironmentStartLiveBackupCopyPayload { + return v.StartLiveBackupCopy +} + +// StartLiveBackupCopyStartLiveBackupCopyAppEnvironmentStartLiveBackupCopyPayload includes the requested fields of the GraphQL type AppEnvironmentStartLiveBackupCopyPayload. +// The GraphQL type's documentation follows. +// +// The result of starting a live backup copy. +type StartLiveBackupCopyStartLiveBackupCopyAppEnvironmentStartLiveBackupCopyPayload struct { + // A human-readable result message. + Message *string `json:"message"` + // The live backup copy ID. + CopyId *string `json:"copyId"` +} + +// GetMessage returns StartLiveBackupCopyStartLiveBackupCopyAppEnvironmentStartLiveBackupCopyPayload.Message, and is useful for accessing the field via an interface. +func (v *StartLiveBackupCopyStartLiveBackupCopyAppEnvironmentStartLiveBackupCopyPayload) GetMessage() *string { + return v.Message +} + +// GetCopyId returns StartLiveBackupCopyStartLiveBackupCopyAppEnvironmentStartLiveBackupCopyPayload.CopyId, and is useful for accessing the field via an interface. +func (v *StartLiveBackupCopyStartLiveBackupCopyAppEnvironmentStartLiveBackupCopyPayload) GetCopyId() *string { + return v.CopyId +} + +// StartMediaImportResponse is returned by StartMediaImport on success. +type StartMediaImportResponse struct { + // Import media into an environment. + StartMediaImport *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload `json:"startMediaImport"` +} + +// GetStartMediaImport returns StartMediaImportResponse.StartMediaImport, and is useful for accessing the field via an interface. +func (v *StartMediaImportResponse) GetStartMediaImport() *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload { + return v.StartMediaImport +} + +// StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload includes the requested fields of the GraphQL type AppEnvironmentMediaImportPayload. +// The GraphQL type's documentation follows. +// +// Response payload for starting and fetching a Media Import +type StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload struct { + // The unique ID of the Application + ApplicationId *int64 `json:"applicationId"` + // The unique ID of the Environment + EnvironmentId *int64 `json:"environmentId"` + // Media Import Status + MediaImportStatus *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus `json:"mediaImportStatus"` +} + +// GetApplicationId returns StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload.ApplicationId, and is useful for accessing the field via an interface. +func (v *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload) GetApplicationId() *int64 { + return v.ApplicationId +} + +// GetEnvironmentId returns StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload.EnvironmentId, and is useful for accessing the field via an interface. +func (v *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload) GetEnvironmentId() *int64 { + return v.EnvironmentId +} + +// GetMediaImportStatus returns StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload.MediaImportStatus, and is useful for accessing the field via an interface. +func (v *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayload) GetMediaImportStatus() *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus { + return v.MediaImportStatus +} + +// StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus includes the requested fields of the GraphQL type AppEnvironmentMediaImportStatus. +// The GraphQL type's documentation follows. +// +// Current status of a Media Import +type StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus struct { + // Unique Identifier for a Media Import + ImportId *int64 `json:"importId"` + // Alias of environmentId + SiteId *int64 `json:"siteId"` + // The actual status of the Media Import + Status *string `json:"status"` +} + +// GetImportId returns StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus.ImportId, and is useful for accessing the field via an interface. +func (v *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus) GetImportId() *int64 { + return v.ImportId +} + +// GetSiteId returns StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus.SiteId, and is useful for accessing the field via an interface. +func (v *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus) GetSiteId() *int64 { + return v.SiteId +} + +// GetStatus returns StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus.Status, and is useful for accessing the field via an interface. +func (v *StartMediaImportStartMediaImportAppEnvironmentMediaImportPayloadMediaImportStatusAppEnvironmentMediaImportStatus) GetStatus() *string { + return v.Status +} + +// SyncEnvironmentResponse is returned by SyncEnvironment on success. +type SyncEnvironmentResponse struct { + // Trigger a sync for an application environment. + SyncEnvironment *SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayload `json:"syncEnvironment"` +} + +// GetSyncEnvironment returns SyncEnvironmentResponse.SyncEnvironment, and is useful for accessing the field via an interface. +func (v *SyncEnvironmentResponse) GetSyncEnvironment() *SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayload { + return v.SyncEnvironment +} + +// SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayload includes the requested fields of the GraphQL type AppEnvironmentSyncPayload. +// The GraphQL type's documentation follows. +// +// The result of triggering an environment sync. +type SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayload struct { + // The environment being synced. + Environment *SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayloadEnvironmentAppEnvironment `json:"environment"` +} + +// GetEnvironment returns SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayload.Environment, and is useful for accessing the field via an interface. +func (v *SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayload) GetEnvironment() *SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayloadEnvironmentAppEnvironment { + return v.Environment +} + +// SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayloadEnvironmentAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayloadEnvironmentAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` +} + +// GetId returns SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayloadEnvironmentAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *SyncEnvironmentSyncEnvironmentAppEnvironmentSyncPayloadEnvironmentAppEnvironment) GetId() *int64 { + return v.Id +} + +// SyncPreviewApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type SyncPreviewApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*SyncPreviewAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns SyncPreviewApp.Id, and is useful for accessing the field via an interface. +func (v *SyncPreviewApp) GetId() *int64 { return v.Id } + +// GetEnvironments returns SyncPreviewApp.Environments, and is useful for accessing the field via an interface. +func (v *SyncPreviewApp) GetEnvironments() []*SyncPreviewAppEnvironmentsAppEnvironment { + return v.Environments +} + +// SyncPreviewAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type SyncPreviewAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // A preview of the next environment sync. + SyncPreview *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview `json:"syncPreview"` +} + +// GetId returns SyncPreviewAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetSyncPreview returns SyncPreviewAppEnvironmentsAppEnvironment.SyncPreview, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironment) GetSyncPreview() *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview { + return v.SyncPreview +} + +// SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview includes the requested fields of the GraphQL type AppEnvironmentSyncPreview. +// The GraphQL type's documentation follows. +// +// A preview of whether an environment can be synced. +type SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview struct { + // Whether the environment can be synced. + CanSync *bool `json:"canSync"` + // The validation errors preventing sync. + Errors []*SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewErrorsAppEnvironmentSyncError `json:"errors"` + // The backup that will be used for sync. + Backup *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewBackupAppEnvironmentBackup `json:"backup"` + // The replacements that will be applied during sync. + Replacements []*SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewReplacementsAppEnvironmentSyncReplacement `json:"replacements"` +} + +// GetCanSync returns SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview.CanSync, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview) GetCanSync() *bool { return v.CanSync } + +// GetErrors returns SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview.Errors, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview) GetErrors() []*SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewErrorsAppEnvironmentSyncError { + return v.Errors +} + +// GetBackup returns SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview.Backup, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview) GetBackup() *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewBackupAppEnvironmentBackup { + return v.Backup +} + +// GetReplacements returns SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview.Replacements, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreview) GetReplacements() []*SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewReplacementsAppEnvironmentSyncReplacement { + return v.Replacements +} + +// SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewBackupAppEnvironmentBackup includes the requested fields of the GraphQL type AppEnvironmentBackup. +// The GraphQL type's documentation follows. +// +// A lightweight backup summary for an environment. +type SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewBackupAppEnvironmentBackup struct { + // When the backup was created. + CreatedAt *string `json:"createdAt"` +} + +// GetCreatedAt returns SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewBackupAppEnvironmentBackup.CreatedAt, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewBackupAppEnvironmentBackup) GetCreatedAt() *string { + return v.CreatedAt +} + +// SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewErrorsAppEnvironmentSyncError includes the requested fields of the GraphQL type AppEnvironmentSyncError. +// The GraphQL type's documentation follows. +// +// A sync validation error. +type SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewErrorsAppEnvironmentSyncError struct { + // The error message. + Message *string `json:"message"` +} + +// GetMessage returns SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewErrorsAppEnvironmentSyncError.Message, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewErrorsAppEnvironmentSyncError) GetMessage() *string { + return v.Message +} + +// SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewReplacementsAppEnvironmentSyncReplacement includes the requested fields of the GraphQL type AppEnvironmentSyncReplacement. +// The GraphQL type's documentation follows. +// +// A string replacement that will be applied during sync. +type SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewReplacementsAppEnvironmentSyncReplacement struct { + // The source value. + From *string `json:"from"` + // The replacement value. + To *string `json:"to"` +} + +// GetFrom returns SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewReplacementsAppEnvironmentSyncReplacement.From, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewReplacementsAppEnvironmentSyncReplacement) GetFrom() *string { + return v.From +} + +// GetTo returns SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewReplacementsAppEnvironmentSyncReplacement.To, and is useful for accessing the field via an interface. +func (v *SyncPreviewAppEnvironmentsAppEnvironmentSyncPreviewReplacementsAppEnvironmentSyncReplacement) GetTo() *string { + return v.To +} + +// SyncPreviewResponse is returned by SyncPreview on success. +type SyncPreviewResponse struct { + // Retrieve a single application. + App *SyncPreviewApp `json:"app"` +} + +// GetApp returns SyncPreviewResponse.App, and is useful for accessing the field via an interface. +func (v *SyncPreviewResponse) GetApp() *SyncPreviewApp { return v.App } + +// SyncProgressApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type SyncProgressApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*SyncProgressAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns SyncProgressApp.Id, and is useful for accessing the field via an interface. +func (v *SyncProgressApp) GetId() *int64 { return v.Id } + +// GetEnvironments returns SyncProgressApp.Environments, and is useful for accessing the field via an interface. +func (v *SyncProgressApp) GetEnvironments() []*SyncProgressAppEnvironmentsAppEnvironment { + return v.Environments +} + +// SyncProgressAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type SyncProgressAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The current sync progress for the environment. + SyncProgress *SyncProgressAppEnvironmentsAppEnvironmentSyncProgress `json:"syncProgress"` +} + +// GetId returns SyncProgressAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *SyncProgressAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetSyncProgress returns SyncProgressAppEnvironmentsAppEnvironment.SyncProgress, and is useful for accessing the field via an interface. +func (v *SyncProgressAppEnvironmentsAppEnvironment) GetSyncProgress() *SyncProgressAppEnvironmentsAppEnvironmentSyncProgress { + return v.SyncProgress +} + +// SyncProgressAppEnvironmentsAppEnvironmentSyncProgress includes the requested fields of the GraphQL type AppEnvironmentSyncProgress. +// The GraphQL type's documentation follows. +// +// Progress details for an environment sync. +type SyncProgressAppEnvironmentsAppEnvironmentSyncProgress struct { + // The overall sync status. + Status *string `json:"status"` + // The sync job ID. + Sync *int64 `json:"sync"` + // The individual sync steps. + Steps []*SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep `json:"steps"` +} + +// GetStatus returns SyncProgressAppEnvironmentsAppEnvironmentSyncProgress.Status, and is useful for accessing the field via an interface. +func (v *SyncProgressAppEnvironmentsAppEnvironmentSyncProgress) GetStatus() *string { return v.Status } + +// GetSync returns SyncProgressAppEnvironmentsAppEnvironmentSyncProgress.Sync, and is useful for accessing the field via an interface. +func (v *SyncProgressAppEnvironmentsAppEnvironmentSyncProgress) GetSync() *int64 { return v.Sync } + +// GetSteps returns SyncProgressAppEnvironmentsAppEnvironmentSyncProgress.Steps, and is useful for accessing the field via an interface. +func (v *SyncProgressAppEnvironmentsAppEnvironmentSyncProgress) GetSteps() []*SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep { + return v.Steps +} + +// SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep includes the requested fields of the GraphQL type AppEnvironmentSyncStep. +// The GraphQL type's documentation follows. +// +// A single step in an environment sync. +type SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep struct { + // The display name of the step. + Name *string `json:"name"` + // The step status. + Status *string `json:"status"` + // The step identifier. + Step *string `json:"step"` +} + +// GetName returns SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep.Name, and is useful for accessing the field via an interface. +func (v *SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep) GetName() *string { + return v.Name +} + +// GetStatus returns SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep.Status, and is useful for accessing the field via an interface. +func (v *SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep) GetStatus() *string { + return v.Status +} + +// GetStep returns SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep.Step, and is useful for accessing the field via an interface. +func (v *SyncProgressAppEnvironmentsAppEnvironmentSyncProgressStepsAppEnvironmentSyncStep) GetStep() *string { + return v.Step +} + +// SyncProgressResponse is returned by SyncProgress on success. +type SyncProgressResponse struct { + // Retrieve a single application. + App *SyncProgressApp `json:"app"` +} + +// GetApp returns SyncProgressResponse.App, and is useful for accessing the field via an interface. +func (v *SyncProgressResponse) GetApp() *SyncProgressApp { return v.App } + +// TriggerDatabaseBackupResponse is returned by TriggerDatabaseBackup on success. +type TriggerDatabaseBackupResponse struct { + // Trigger a database backup. + TriggerDatabaseBackup *TriggerDatabaseBackupTriggerDatabaseBackupAppEnvironmentTriggerDBBackupPayload `json:"triggerDatabaseBackup"` +} + +// GetTriggerDatabaseBackup returns TriggerDatabaseBackupResponse.TriggerDatabaseBackup, and is useful for accessing the field via an interface. +func (v *TriggerDatabaseBackupResponse) GetTriggerDatabaseBackup() *TriggerDatabaseBackupTriggerDatabaseBackupAppEnvironmentTriggerDBBackupPayload { + return v.TriggerDatabaseBackup +} + +// TriggerDatabaseBackupTriggerDatabaseBackupAppEnvironmentTriggerDBBackupPayload includes the requested fields of the GraphQL type AppEnvironmentTriggerDBBackupPayload. +// The GraphQL type's documentation follows. +// +// The result of triggering a database backup. +type TriggerDatabaseBackupTriggerDatabaseBackupAppEnvironmentTriggerDBBackupPayload struct { + // Whether the operation succeeded. + Success *bool `json:"success"` +} + +// GetSuccess returns TriggerDatabaseBackupTriggerDatabaseBackupAppEnvironmentTriggerDBBackupPayload.Success, and is useful for accessing the field via an interface. +func (v *TriggerDatabaseBackupTriggerDatabaseBackupAppEnvironmentTriggerDBBackupPayload) GetSuccess() *bool { + return v.Success +} + +// TriggerWPCLICommandResponse is returned by TriggerWPCLICommand on success. +type TriggerWPCLICommandResponse struct { + // Execute a WP-CLI command on an environment. + TriggerWPCLICommandOnAppEnvironment *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload `json:"triggerWPCLICommandOnAppEnvironment"` +} + +// GetTriggerWPCLICommandOnAppEnvironment returns TriggerWPCLICommandResponse.TriggerWPCLICommandOnAppEnvironment, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandResponse) GetTriggerWPCLICommandOnAppEnvironment() *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload { + return v.TriggerWPCLICommandOnAppEnvironment +} + +// TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload includes the requested fields of the GraphQL type AppEnvironmentTriggerWPCLICommandPayload. +// The GraphQL type's documentation follows. +// +// Response from the Run WP-CLI Command mutation +type TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload struct { + // The token for authenticating the socket connection + InputToken *string `json:"inputToken"` + // The command that was executed + Command *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadCommandWPCLICommand `json:"command"` + // The SSH credentials for connecting to the command session. + SshAuthentication *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication `json:"sshAuthentication"` +} + +// GetInputToken returns TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload.InputToken, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload) GetInputToken() *string { + return v.InputToken +} + +// GetCommand returns TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload.Command, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload) GetCommand() *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadCommandWPCLICommand { + return v.Command +} + +// GetSshAuthentication returns TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload.SshAuthentication, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayload) GetSshAuthentication() *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication { + return v.SshAuthentication +} + +// TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadCommandWPCLICommand includes the requested fields of the GraphQL type WPCLICommand. +// The GraphQL type's documentation follows. +// +// A WP-CLI command executed on an application environment. +type TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadCommandWPCLICommand struct { + // The GUID for the command. + Guid *string `json:"guid"` +} + +// GetGuid returns TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadCommandWPCLICommand.Guid, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadCommandWPCLICommand) GetGuid() *string { + return v.Guid +} + +// TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication includes the requested fields of the GraphQL type WPCliSSHAuthentication. +// The GraphQL type's documentation follows. +// +// SSH credentials for running a WP-CLI command. +type TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication struct { + // The SSH host. + Host string `json:"host"` + // The SSH port. + Port string `json:"port"` + // The SSH username. + Username string `json:"username"` + // The private key used for authentication. + PrivateKey string `json:"privateKey"` + // The passphrase for the private key. + Passphrase string `json:"passphrase"` +} + +// GetHost returns TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication.Host, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication) GetHost() string { + return v.Host +} + +// GetPort returns TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication.Port, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication) GetPort() string { + return v.Port +} + +// GetUsername returns TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication.Username, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication) GetUsername() string { + return v.Username +} + +// GetPrivateKey returns TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication.PrivateKey, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication) GetPrivateKey() string { + return v.PrivateKey +} + +// GetPassphrase returns TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication.Passphrase, and is useful for accessing the field via an interface. +func (v *TriggerWPCLICommandTriggerWPCLICommandOnAppEnvironmentAppEnvironmentTriggerWPCLICommandPayloadSshAuthenticationWPCliSSHAuthentication) GetPassphrase() string { + return v.Passphrase +} + +// UpdateDefensiveModeConfigResponse is returned by UpdateDefensiveModeConfig on success. +type UpdateDefensiveModeConfigResponse struct { + // Update defensive mode configuration. + UpdateDefensiveModeConfig *UpdateDefensiveModeConfigUpdateDefensiveModeConfigAppEnvironmentDefensiveModeOperationResultPayload `json:"updateDefensiveModeConfig"` +} + +// GetUpdateDefensiveModeConfig returns UpdateDefensiveModeConfigResponse.UpdateDefensiveModeConfig, and is useful for accessing the field via an interface. +func (v *UpdateDefensiveModeConfigResponse) GetUpdateDefensiveModeConfig() *UpdateDefensiveModeConfigUpdateDefensiveModeConfigAppEnvironmentDefensiveModeOperationResultPayload { + return v.UpdateDefensiveModeConfig +} + +// UpdateDefensiveModeConfigUpdateDefensiveModeConfigAppEnvironmentDefensiveModeOperationResultPayload includes the requested fields of the GraphQL type AppEnvironmentDefensiveModeOperationResultPayload. +// The GraphQL type's documentation follows. +// +// The result of a defensive mode operation. +type UpdateDefensiveModeConfigUpdateDefensiveModeConfigAppEnvironmentDefensiveModeOperationResultPayload struct { + // Whether the operation succeeded. + Success bool `json:"success"` + // A human-readable result message. + Message string `json:"message"` +} + +// GetSuccess returns UpdateDefensiveModeConfigUpdateDefensiveModeConfigAppEnvironmentDefensiveModeOperationResultPayload.Success, and is useful for accessing the field via an interface. +func (v *UpdateDefensiveModeConfigUpdateDefensiveModeConfigAppEnvironmentDefensiveModeOperationResultPayload) GetSuccess() bool { + return v.Success +} + +// GetMessage returns UpdateDefensiveModeConfigUpdateDefensiveModeConfigAppEnvironmentDefensiveModeOperationResultPayload.Message, and is useful for accessing the field via an interface. +func (v *UpdateDefensiveModeConfigUpdateDefensiveModeConfigAppEnvironmentDefensiveModeOperationResultPayload) GetMessage() string { + return v.Message +} + +// UpdateDefensiveModeStatusResponse is returned by UpdateDefensiveModeStatus on success. +type UpdateDefensiveModeStatusResponse struct { + // Enable or disable defensive mode. + UpdateDefensiveModeStatus *UpdateDefensiveModeStatusUpdateDefensiveModeStatusAppEnvironmentDefensiveModeOperationResultPayload `json:"updateDefensiveModeStatus"` +} + +// GetUpdateDefensiveModeStatus returns UpdateDefensiveModeStatusResponse.UpdateDefensiveModeStatus, and is useful for accessing the field via an interface. +func (v *UpdateDefensiveModeStatusResponse) GetUpdateDefensiveModeStatus() *UpdateDefensiveModeStatusUpdateDefensiveModeStatusAppEnvironmentDefensiveModeOperationResultPayload { + return v.UpdateDefensiveModeStatus +} + +// UpdateDefensiveModeStatusUpdateDefensiveModeStatusAppEnvironmentDefensiveModeOperationResultPayload includes the requested fields of the GraphQL type AppEnvironmentDefensiveModeOperationResultPayload. +// The GraphQL type's documentation follows. +// +// The result of a defensive mode operation. +type UpdateDefensiveModeStatusUpdateDefensiveModeStatusAppEnvironmentDefensiveModeOperationResultPayload struct { + // Whether the operation succeeded. + Success bool `json:"success"` + // A human-readable result message. + Message string `json:"message"` +} + +// GetSuccess returns UpdateDefensiveModeStatusUpdateDefensiveModeStatusAppEnvironmentDefensiveModeOperationResultPayload.Success, and is useful for accessing the field via an interface. +func (v *UpdateDefensiveModeStatusUpdateDefensiveModeStatusAppEnvironmentDefensiveModeOperationResultPayload) GetSuccess() bool { + return v.Success +} + +// GetMessage returns UpdateDefensiveModeStatusUpdateDefensiveModeStatusAppEnvironmentDefensiveModeOperationResultPayload.Message, and is useful for accessing the field via an interface. +func (v *UpdateDefensiveModeStatusUpdateDefensiveModeStatusAppEnvironmentDefensiveModeOperationResultPayload) GetMessage() string { + return v.Message +} + +// UpdateSoftwareSettingsResponse is returned by UpdateSoftwareSettings on success. +type UpdateSoftwareSettingsResponse struct { + // Update software settings for an application environment. + UpdateSoftwareSettings *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings `json:"updateSoftwareSettings"` +} + +// GetUpdateSoftwareSettings returns UpdateSoftwareSettingsResponse.UpdateSoftwareSettings, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsResponse) GetUpdateSoftwareSettings() *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings { + return v.UpdateSoftwareSettings +} + +// UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettings. +// The GraphQL type's documentation follows. +// +// Available software settings for an application environment. +type UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings struct { + // The WordPress software settings. + Wordpress *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware `json:"wordpress"` + // The PHP software settings. + Php *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware `json:"php"` + // The mu-plugins software settings. + Muplugins *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware `json:"muplugins"` + // The Node.js software settings. + Nodejs *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware `json:"nodejs"` +} + +// GetWordpress returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings.Wordpress, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings) GetWordpress() *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware { + return v.Wordpress +} + +// GetPhp returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings.Php, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings) GetPhp() *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware { + return v.Php +} + +// GetMuplugins returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings.Muplugins, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings) GetMuplugins() *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware { + return v.Muplugins +} + +// GetNodejs returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings.Nodejs, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettings) GetNodejs() *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware { + return v.Nodejs +} + +// UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware struct { + SoftwareNode `json:"-"` +} + +// GetName returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Name, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetName() string { + return v.SoftwareNode.Name +} + +// GetSlug returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Slug, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetSlug() string { + return v.SoftwareNode.Slug +} + +// GetPinned returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Pinned, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetPinned() bool { + return v.SoftwareNode.Pinned +} + +// GetCurrent returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Current +} + +// GetOptions returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware.Options, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) GetOptions() []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Options +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware + graphql.NoUnmarshalJSON + } + firstPass.UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.SoftwareNode) + if err != nil { + return err + } + return nil +} + +type __premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware struct { + Name string `json:"name"` + + Slug string `json:"slug"` + + Pinned bool `json:"pinned"` + + Current *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` + + Options []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion `json:"options"` +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware) __premarshalJSON() (*__premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware, error) { + var retval __premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsMupluginsAppEnvironmentSoftwareSettingsSoftware + + retval.Name = v.SoftwareNode.Name + retval.Slug = v.SoftwareNode.Slug + retval.Pinned = v.SoftwareNode.Pinned + retval.Current = v.SoftwareNode.Current + retval.Options = v.SoftwareNode.Options + return &retval, nil +} + +// UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware struct { + SoftwareNode `json:"-"` +} + +// GetName returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Name, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetName() string { + return v.SoftwareNode.Name +} + +// GetSlug returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Slug, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetSlug() string { + return v.SoftwareNode.Slug +} + +// GetPinned returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Pinned, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetPinned() bool { + return v.SoftwareNode.Pinned +} + +// GetCurrent returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Current +} + +// GetOptions returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware.Options, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) GetOptions() []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Options +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware + graphql.NoUnmarshalJSON + } + firstPass.UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.SoftwareNode) + if err != nil { + return err + } + return nil +} + +type __premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware struct { + Name string `json:"name"` + + Slug string `json:"slug"` + + Pinned bool `json:"pinned"` + + Current *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` + + Options []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion `json:"options"` +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware) __premarshalJSON() (*__premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware, error) { + var retval __premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsNodejsAppEnvironmentSoftwareSettingsSoftware + + retval.Name = v.SoftwareNode.Name + retval.Slug = v.SoftwareNode.Slug + retval.Pinned = v.SoftwareNode.Pinned + retval.Current = v.SoftwareNode.Current + retval.Options = v.SoftwareNode.Options + return &retval, nil +} + +// UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware struct { + SoftwareNode `json:"-"` +} + +// GetName returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Name, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetName() string { + return v.SoftwareNode.Name +} + +// GetSlug returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Slug, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetSlug() string { + return v.SoftwareNode.Slug +} + +// GetPinned returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Pinned, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetPinned() bool { + return v.SoftwareNode.Pinned +} + +// GetCurrent returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Current +} + +// GetOptions returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware.Options, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) GetOptions() []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Options +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware + graphql.NoUnmarshalJSON + } + firstPass.UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.SoftwareNode) + if err != nil { + return err + } + return nil +} + +type __premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware struct { + Name string `json:"name"` + + Slug string `json:"slug"` + + Pinned bool `json:"pinned"` + + Current *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` + + Options []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion `json:"options"` +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware) __premarshalJSON() (*__premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware, error) { + var retval __premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsPhpAppEnvironmentSoftwareSettingsSoftware + + retval.Name = v.SoftwareNode.Name + retval.Slug = v.SoftwareNode.Slug + retval.Pinned = v.SoftwareNode.Pinned + retval.Current = v.SoftwareNode.Current + retval.Options = v.SoftwareNode.Options + return &retval, nil +} + +// UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware includes the requested fields of the GraphQL type AppEnvironmentSoftwareSettingsSoftware. +// The GraphQL type's documentation follows. +// +// Software settings and available versions for one software package. +type UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware struct { + SoftwareNode `json:"-"` +} + +// GetName returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Name, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetName() string { + return v.SoftwareNode.Name +} + +// GetSlug returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Slug, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetSlug() string { + return v.SoftwareNode.Slug +} + +// GetPinned returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Pinned, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetPinned() bool { + return v.SoftwareNode.Pinned +} + +// GetCurrent returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Current, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetCurrent() *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Current +} + +// GetOptions returns UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware.Options, and is useful for accessing the field via an interface. +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) GetOptions() []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion { + return v.SoftwareNode.Options +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware + graphql.NoUnmarshalJSON + } + firstPass.UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.SoftwareNode) + if err != nil { + return err + } + return nil +} + +type __premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware struct { + Name string `json:"name"` + + Slug string `json:"slug"` + + Pinned bool `json:"pinned"` + + Current *SoftwareNodeCurrentAppEnvironmentSoftwareSettingsVersion `json:"current"` + + Options []*SoftwareNodeOptionsAppEnvironmentSoftwareSettingsVersion `json:"options"` +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) +} + +func (v *UpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware) __premarshalJSON() (*__premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware, error) { + var retval __premarshalUpdateSoftwareSettingsUpdateSoftwareSettingsAppEnvironmentSoftwareSettingsWordpressAppEnvironmentSoftwareSettingsSoftware + + retval.Name = v.SoftwareNode.Name + retval.Slug = v.SoftwareNode.Slug + retval.Pinned = v.SoftwareNode.Pinned + retval.Current = v.SoftwareNode.Current + retval.Options = v.SoftwareNode.Options + return &retval, nil +} + +// Input for validating custom deploy access. +type ValidateCustomDeployAccessInput struct { + // The application identifier to validate. + App string `json:"app"` + // The environment identifier to validate. + Env string `json:"env"` +} + +// GetApp returns ValidateCustomDeployAccessInput.App, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessInput) GetApp() string { return v.App } + +// GetEnv returns ValidateCustomDeployAccessInput.Env, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessInput) GetEnv() string { return v.Env } + +// ValidateCustomDeployAccessResponse is returned by ValidateCustomDeployAccess on success. +type ValidateCustomDeployAccessResponse struct { + // Validate custom deploy access for an application and environment. + ValidateCustomDeployAccess *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload `json:"validateCustomDeployAccess"` +} + +// GetValidateCustomDeployAccess returns ValidateCustomDeployAccessResponse.ValidateCustomDeployAccess, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessResponse) GetValidateCustomDeployAccess() *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload { + return v.ValidateCustomDeployAccess +} + +// ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload includes the requested fields of the GraphQL type ValidateCustomDeployAccessPayload. +// The GraphQL type's documentation follows. +// +// The result of validating custom deploy access. +type ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload struct { + // Whether the custom deploy access is valid. + Success *bool `json:"success"` + // The resolved application ID. + AppId *int64 `json:"appId"` + // The resolved environment ID. + EnvId *int64 `json:"envId"` + // The resolved environment type. + EnvType *string `json:"envType"` + // The resolved unique environment label. + EnvUniqueLabel *string `json:"envUniqueLabel"` + // The primary domain name for the environment. + PrimaryDomainName *string `json:"primaryDomainName"` + // Whether the environment is launched. + Launched *bool `json:"launched"` +} + +// GetSuccess returns ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload.Success, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload) GetSuccess() *bool { + return v.Success +} + +// GetAppId returns ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload.AppId, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload) GetAppId() *int64 { + return v.AppId +} + +// GetEnvId returns ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload.EnvId, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload) GetEnvId() *int64 { + return v.EnvId +} + +// GetEnvType returns ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload.EnvType, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload) GetEnvType() *string { + return v.EnvType +} + +// GetEnvUniqueLabel returns ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload.EnvUniqueLabel, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload) GetEnvUniqueLabel() *string { + return v.EnvUniqueLabel +} + +// GetPrimaryDomainName returns ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload.PrimaryDomainName, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload) GetPrimaryDomainName() *string { + return v.PrimaryDomainName +} + +// GetLaunched returns ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload.Launched, and is useful for accessing the field via an interface. +func (v *ValidateCustomDeployAccessValidateCustomDeployAccessValidateCustomDeployAccessPayload) GetLaunched() *bool { + return v.Launched +} + +// WPEnvInfoApp includes the requested fields of the GraphQL type App. +// The GraphQL type's documentation follows. +// +// An application managed in WordPress VIP. This is the primary entry point for traversing into environment-level operational reads. +type WPEnvInfoApp struct { + // The unique identifier for the application. + Id *int64 `json:"id"` + // The display name of the application. + Name *string `json:"name"` + // The internal numeric identifier for the application type. + TypeId *int64 `json:"typeId"` + // The environments that belong to this application. Use this for environment discovery by ID, name, or type before selecting nested operational fields. + Environments []*WPEnvInfoAppEnvironmentsAppEnvironment `json:"environments"` +} + +// GetId returns WPEnvInfoApp.Id, and is useful for accessing the field via an interface. +func (v *WPEnvInfoApp) GetId() *int64 { return v.Id } + +// GetName returns WPEnvInfoApp.Name, and is useful for accessing the field via an interface. +func (v *WPEnvInfoApp) GetName() *string { return v.Name } + +// GetTypeId returns WPEnvInfoApp.TypeId, and is useful for accessing the field via an interface. +func (v *WPEnvInfoApp) GetTypeId() *int64 { return v.TypeId } + +// GetEnvironments returns WPEnvInfoApp.Environments, and is useful for accessing the field via an interface. +func (v *WPEnvInfoApp) GetEnvironments() []*WPEnvInfoAppEnvironmentsAppEnvironment { + return v.Environments +} + +// WPEnvInfoAppEnvironmentsAppEnvironment includes the requested fields of the GraphQL type AppEnvironment. +// The GraphQL type's documentation follows. +// +// An application environment in WordPress VIP. This type is the main operational read surface and includes commands, logs, events, backups, deployments, metrics, integrations, and security controls. +type WPEnvInfoAppEnvironmentsAppEnvironment struct { + // The unique identifier for the environment. + Id *int64 `json:"id"` + // The application ID that owns the environment. + AppId *int64 `json:"appId"` + // The environment type, such as production or develop. + Type *string `json:"type"` + // The display name of the environment. + Name *string `json:"name"` + // The strategy used to execute WP-CLI commands. + WpcliStrategy *AppEnvironmentWPCliStrategy `json:"wpcliStrategy"` + // The primary domain for the environment. + PrimaryDomain *WPEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain `json:"primaryDomain"` +} + +// GetId returns WPEnvInfoAppEnvironmentsAppEnvironment.Id, and is useful for accessing the field via an interface. +func (v *WPEnvInfoAppEnvironmentsAppEnvironment) GetId() *int64 { return v.Id } + +// GetAppId returns WPEnvInfoAppEnvironmentsAppEnvironment.AppId, and is useful for accessing the field via an interface. +func (v *WPEnvInfoAppEnvironmentsAppEnvironment) GetAppId() *int64 { return v.AppId } + +// GetType returns WPEnvInfoAppEnvironmentsAppEnvironment.Type, and is useful for accessing the field via an interface. +func (v *WPEnvInfoAppEnvironmentsAppEnvironment) GetType() *string { return v.Type } + +// GetName returns WPEnvInfoAppEnvironmentsAppEnvironment.Name, and is useful for accessing the field via an interface. +func (v *WPEnvInfoAppEnvironmentsAppEnvironment) GetName() *string { return v.Name } + +// GetWpcliStrategy returns WPEnvInfoAppEnvironmentsAppEnvironment.WpcliStrategy, and is useful for accessing the field via an interface. +func (v *WPEnvInfoAppEnvironmentsAppEnvironment) GetWpcliStrategy() *AppEnvironmentWPCliStrategy { + return v.WpcliStrategy +} + +// GetPrimaryDomain returns WPEnvInfoAppEnvironmentsAppEnvironment.PrimaryDomain, and is useful for accessing the field via an interface. +func (v *WPEnvInfoAppEnvironmentsAppEnvironment) GetPrimaryDomain() *WPEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain { + return v.PrimaryDomain +} + +// WPEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain includes the requested fields of the GraphQL type Domain. +// The GraphQL type's documentation follows. +// +// A domain for an environment +type WPEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain struct { + // The domain name (i.e. something like example.com or sub.example.com) + Name string `json:"name"` +} + +// GetName returns WPEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain.Name, and is useful for accessing the field via an interface. +func (v *WPEnvInfoAppEnvironmentsAppEnvironmentPrimaryDomain) GetName() string { return v.Name } + +// WPEnvInfoResponse is returned by WPEnvInfo on success. +type WPEnvInfoResponse struct { + // Retrieve a single application. + App *WPEnvInfoApp `json:"app"` +} + +// GetApp returns WPEnvInfoResponse.App, and is useful for accessing the field via an interface. +func (v *WPEnvInfoResponse) GetApp() *WPEnvInfoApp { return v.App } + +// __AbortMediaImportInput is used internally by genqlient +type __AbortMediaImportInput struct { + Input *AppEnvironmentAbortMediaImportInput `json:"input,omitempty"` +} + +// GetInput returns __AbortMediaImportInput.Input, and is useful for accessing the field via an interface. +func (v *__AbortMediaImportInput) GetInput() *AppEnvironmentAbortMediaImportInput { return v.Input } + +// __AddEnvironmentVariableInput is used internally by genqlient +type __AddEnvironmentVariableInput struct { + Input *EnvironmentVariableInput `json:"input,omitempty"` +} + +// GetInput returns __AddEnvironmentVariableInput.Input, and is useful for accessing the field via an interface. +func (v *__AddEnvironmentVariableInput) GetInput() *EnvironmentVariableInput { return v.Input } + +// __AppBackupAndJobStatusInput is used internally by genqlient +type __AppBackupAndJobStatusInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __AppBackupAndJobStatusInput.AppId, and is useful for accessing the field via an interface. +func (v *__AppBackupAndJobStatusInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __AppBackupAndJobStatusInput.EnvId, and is useful for accessing the field via an interface. +func (v *__AppBackupAndJobStatusInput) GetEnvId() int64 { return v.EnvId } + +// __AppBackupJobStatusInput is used internally by genqlient +type __AppBackupJobStatusInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __AppBackupJobStatusInput.AppId, and is useful for accessing the field via an interface. +func (v *__AppBackupJobStatusInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __AppBackupJobStatusInput.EnvId, and is useful for accessing the field via an interface. +func (v *__AppBackupJobStatusInput) GetEnvId() int64 { return v.EnvId } + +// __AppGetByIDInput is used internally by genqlient +type __AppGetByIDInput struct { + Id int64 `json:"id"` +} + +// GetId returns __AppGetByIDInput.Id, and is useful for accessing the field via an interface. +func (v *__AppGetByIDInput) GetId() int64 { return v.Id } + +// __AppGetByNameInput is used internally by genqlient +type __AppGetByNameInput struct { + Name string `json:"name"` +} + +// GetName returns __AppGetByNameInput.Name, and is useful for accessing the field via an interface. +func (v *__AppGetByNameInput) GetName() string { return v.Name } + +// __AppListInput is used internally by genqlient +type __AppListInput struct { + First *int64 `json:"first"` + After *string `json:"after"` +} + +// GetFirst returns __AppListInput.First, and is useful for accessing the field via an interface. +func (v *__AppListInput) GetFirst() *int64 { return v.First } + +// GetAfter returns __AppListInput.After, and is useful for accessing the field via an interface. +func (v *__AppListInput) GetAfter() *string { return v.After } + +// __AppMappedDomainsInput is used internally by genqlient +type __AppMappedDomainsInput struct { + AppId *int64 `json:"appId"` + EnvId *int64 `json:"envId"` +} + +// GetAppId returns __AppMappedDomainsInput.AppId, and is useful for accessing the field via an interface. +func (v *__AppMappedDomainsInput) GetAppId() *int64 { return v.AppId } + +// GetEnvId returns __AppMappedDomainsInput.EnvId, and is useful for accessing the field via an interface. +func (v *__AppMappedDomainsInput) GetEnvId() *int64 { return v.EnvId } + +// __AppMultiSiteCheckInput is used internally by genqlient +type __AppMultiSiteCheckInput struct { + AppId *int64 `json:"appId"` + EnvId *int64 `json:"envId"` +} + +// GetAppId returns __AppMultiSiteCheckInput.AppId, and is useful for accessing the field via an interface. +func (v *__AppMultiSiteCheckInput) GetAppId() *int64 { return v.AppId } + +// GetEnvId returns __AppMultiSiteCheckInput.EnvId, and is useful for accessing the field via an interface. +func (v *__AppMultiSiteCheckInput) GetEnvId() *int64 { return v.EnvId } + +// __BackupDBCopyInput is used internally by genqlient +type __BackupDBCopyInput struct { + Input *AppEnvironmentStartDBBackupCopyInput `json:"input,omitempty"` +} + +// GetInput returns __BackupDBCopyInput.Input, and is useful for accessing the field via an interface. +func (v *__BackupDBCopyInput) GetInput() *AppEnvironmentStartDBBackupCopyInput { return v.Input } + +// __DeleteEnvironmentVariableInput is used internally by genqlient +type __DeleteEnvironmentVariableInput struct { + Input *EnvironmentVariableInput `json:"input,omitempty"` +} + +// GetInput returns __DeleteEnvironmentVariableInput.Input, and is useful for accessing the field via an interface. +func (v *__DeleteEnvironmentVariableInput) GetInput() *EnvironmentVariableInput { return v.Input } + +// __DevEnvAppInfoInput is used internally by genqlient +type __DevEnvAppInfoInput struct { + AppId int64 `json:"appId"` +} + +// GetAppId returns __DevEnvAppInfoInput.AppId, and is useful for accessing the field via an interface. +func (v *__DevEnvAppInfoInput) GetAppId() int64 { return v.AppId } + +// __DevEnvSyncSitesInput is used internally by genqlient +type __DevEnvSyncSitesInput struct { + AppId int64 `json:"appId"` + EnvironmentId int64 `json:"environmentId"` + After *string `json:"after"` + First int64 `json:"first"` +} + +// GetAppId returns __DevEnvSyncSitesInput.AppId, and is useful for accessing the field via an interface. +func (v *__DevEnvSyncSitesInput) GetAppId() int64 { return v.AppId } + +// GetEnvironmentId returns __DevEnvSyncSitesInput.EnvironmentId, and is useful for accessing the field via an interface. +func (v *__DevEnvSyncSitesInput) GetEnvironmentId() int64 { return v.EnvironmentId } + +// GetAfter returns __DevEnvSyncSitesInput.After, and is useful for accessing the field via an interface. +func (v *__DevEnvSyncSitesInput) GetAfter() *string { return v.After } + +// GetFirst returns __DevEnvSyncSitesInput.First, and is useful for accessing the field via an interface. +func (v *__DevEnvSyncSitesInput) GetFirst() int64 { return v.First } + +// __EnablePhpMyAdminInput is used internally by genqlient +type __EnablePhpMyAdminInput struct { + Input *EnablePhpMyAdminInput `json:"input,omitempty"` +} + +// GetInput returns __EnablePhpMyAdminInput.Input, and is useful for accessing the field via an interface. +func (v *__EnablePhpMyAdminInput) GetInput() *EnablePhpMyAdminInput { return v.Input } + +// __GenerateDBBackupCopyUrlInput is used internally by genqlient +type __GenerateDBBackupCopyUrlInput struct { + Input *AppEnvironmentGenerateDBBackupCopyUrlInput `json:"input,omitempty"` +} + +// GetInput returns __GenerateDBBackupCopyUrlInput.Input, and is useful for accessing the field via an interface. +func (v *__GenerateDBBackupCopyUrlInput) GetInput() *AppEnvironmentGenerateDBBackupCopyUrlInput { + return v.Input +} + +// __GenerateLiveBackupCopyDownloadURLInput is used internally by genqlient +type __GenerateLiveBackupCopyDownloadURLInput struct { + Input *AppEnvironmentLiveBackupCopyDownloadURLInput `json:"input,omitempty"` +} + +// GetInput returns __GenerateLiveBackupCopyDownloadURLInput.Input, and is useful for accessing the field via an interface. +func (v *__GenerateLiveBackupCopyDownloadURLInput) GetInput() *AppEnvironmentLiveBackupCopyDownloadURLInput { + return v.Input +} + +// __GeneratePhpMyAdminAccessInput is used internally by genqlient +type __GeneratePhpMyAdminAccessInput struct { + Input *GeneratePhpMyAdminAccessInput `json:"input,omitempty"` +} + +// GetInput returns __GeneratePhpMyAdminAccessInput.Input, and is useful for accessing the field via an interface. +func (v *__GeneratePhpMyAdminAccessInput) GetInput() *GeneratePhpMyAdminAccessInput { return v.Input } + +// __GetAppLogsInput is used internally by genqlient +type __GetAppLogsInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` + LogType AppEnvironmentLogType `json:"logType"` + Limit int64 `json:"limit"` + After *string `json:"after"` +} + +// GetAppId returns __GetAppLogsInput.AppId, and is useful for accessing the field via an interface. +func (v *__GetAppLogsInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __GetAppLogsInput.EnvId, and is useful for accessing the field via an interface. +func (v *__GetAppLogsInput) GetEnvId() int64 { return v.EnvId } + +// GetLogType returns __GetAppLogsInput.LogType, and is useful for accessing the field via an interface. +func (v *__GetAppLogsInput) GetLogType() AppEnvironmentLogType { return v.LogType } + +// GetLimit returns __GetAppLogsInput.Limit, and is useful for accessing the field via an interface. +func (v *__GetAppLogsInput) GetLimit() int64 { return v.Limit } + +// GetAfter returns __GetAppLogsInput.After, and is useful for accessing the field via an interface. +func (v *__GetAppLogsInput) GetAfter() *string { return v.After } + +// __GetAppSlowlogsInput is used internally by genqlient +type __GetAppSlowlogsInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` + Limit int64 `json:"limit"` + After *string `json:"after"` +} + +// GetAppId returns __GetAppSlowlogsInput.AppId, and is useful for accessing the field via an interface. +func (v *__GetAppSlowlogsInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __GetAppSlowlogsInput.EnvId, and is useful for accessing the field via an interface. +func (v *__GetAppSlowlogsInput) GetEnvId() int64 { return v.EnvId } + +// GetLimit returns __GetAppSlowlogsInput.Limit, and is useful for accessing the field via an interface. +func (v *__GetAppSlowlogsInput) GetLimit() int64 { return v.Limit } + +// GetAfter returns __GetAppSlowlogsInput.After, and is useful for accessing the field via an interface. +func (v *__GetAppSlowlogsInput) GetAfter() *string { return v.After } + +// __GetEnvironmentVariablesInput is used internally by genqlient +type __GetEnvironmentVariablesInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __GetEnvironmentVariablesInput.AppId, and is useful for accessing the field via an interface. +func (v *__GetEnvironmentVariablesInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __GetEnvironmentVariablesInput.EnvId, and is useful for accessing the field via an interface. +func (v *__GetEnvironmentVariablesInput) GetEnvId() int64 { return v.EnvId } + +// __GetEnvironmentVariablesWithValuesInput is used internally by genqlient +type __GetEnvironmentVariablesWithValuesInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __GetEnvironmentVariablesWithValuesInput.AppId, and is useful for accessing the field via an interface. +func (v *__GetEnvironmentVariablesWithValuesInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __GetEnvironmentVariablesWithValuesInput.EnvId, and is useful for accessing the field via an interface. +func (v *__GetEnvironmentVariablesWithValuesInput) GetEnvId() int64 { return v.EnvId } + +// __ImportSQLEnvInfoInput is used internally by genqlient +type __ImportSQLEnvInfoInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __ImportSQLEnvInfoInput.AppId, and is useful for accessing the field via an interface. +func (v *__ImportSQLEnvInfoInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __ImportSQLEnvInfoInput.EnvId, and is useful for accessing the field via an interface. +func (v *__ImportSQLEnvInfoInput) GetEnvId() int64 { return v.EnvId } + +// __ImportSQLProgressInput is used internally by genqlient +type __ImportSQLProgressInput struct { + AppId *int64 `json:"appId"` + EnvId *int64 `json:"envId"` +} + +// GetAppId returns __ImportSQLProgressInput.AppId, and is useful for accessing the field via an interface. +func (v *__ImportSQLProgressInput) GetAppId() *int64 { return v.AppId } + +// GetEnvId returns __ImportSQLProgressInput.EnvId, and is useful for accessing the field via an interface. +func (v *__ImportSQLProgressInput) GetEnvId() *int64 { return v.EnvId } + +// __MediaImportProgressInput is used internally by genqlient +type __MediaImportProgressInput struct { + AppId *int64 `json:"appId"` + EnvId *int64 `json:"envId"` +} + +// GetAppId returns __MediaImportProgressInput.AppId, and is useful for accessing the field via an interface. +func (v *__MediaImportProgressInput) GetAppId() *int64 { return v.AppId } + +// GetEnvId returns __MediaImportProgressInput.EnvId, and is useful for accessing the field via an interface. +func (v *__MediaImportProgressInput) GetEnvId() *int64 { return v.EnvId } + +// __PhpMyAdminStatusInput is used internally by genqlient +type __PhpMyAdminStatusInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __PhpMyAdminStatusInput.AppId, and is useful for accessing the field via an interface. +func (v *__PhpMyAdminStatusInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __PhpMyAdminStatusInput.EnvId, and is useful for accessing the field via an interface. +func (v *__PhpMyAdminStatusInput) GetEnvId() int64 { return v.EnvId } + +// __PurgePageCacheInput is used internally by genqlient +type __PurgePageCacheInput struct { + Input *PurgePageCacheInput `json:"input,omitempty"` +} + +// GetInput returns __PurgePageCacheInput.Input, and is useful for accessing the field via an interface. +func (v *__PurgePageCacheInput) GetInput() *PurgePageCacheInput { return v.Input } + +// __ResolveAppByIDInput is used internally by genqlient +type __ResolveAppByIDInput struct { + Id int64 `json:"id"` +} + +// GetId returns __ResolveAppByIDInput.Id, and is useful for accessing the field via an interface. +func (v *__ResolveAppByIDInput) GetId() int64 { return v.Id } + +// __ResolveAppByNameInput is used internally by genqlient +type __ResolveAppByNameInput struct { + Name string `json:"name"` +} + +// GetName returns __ResolveAppByNameInput.Name, and is useful for accessing the field via an interface. +func (v *__ResolveAppByNameInput) GetName() string { return v.Name } + +// __SoftwareSettingsInput is used internally by genqlient +type __SoftwareSettingsInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __SoftwareSettingsInput.AppId, and is useful for accessing the field via an interface. +func (v *__SoftwareSettingsInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __SoftwareSettingsInput.EnvId, and is useful for accessing the field via an interface. +func (v *__SoftwareSettingsInput) GetEnvId() int64 { return v.EnvId } + +// __SoftwareUpdateJobInput is used internally by genqlient +type __SoftwareUpdateJobInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __SoftwareUpdateJobInput.AppId, and is useful for accessing the field via an interface. +func (v *__SoftwareUpdateJobInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __SoftwareUpdateJobInput.EnvId, and is useful for accessing the field via an interface. +func (v *__SoftwareUpdateJobInput) GetEnvId() int64 { return v.EnvId } + +// __StartCustomDeployInput is used internally by genqlient +type __StartCustomDeployInput struct { + Input *AppEnvironmentCustomDeployInput `json:"input,omitempty"` +} + +// GetInput returns __StartCustomDeployInput.Input, and is useful for accessing the field via an interface. +func (v *__StartCustomDeployInput) GetInput() *AppEnvironmentCustomDeployInput { return v.Input } + +// __StartImportInput is used internally by genqlient +type __StartImportInput struct { + Input *AppEnvironmentImportInput `json:"input,omitempty"` +} + +// GetInput returns __StartImportInput.Input, and is useful for accessing the field via an interface. +func (v *__StartImportInput) GetInput() *AppEnvironmentImportInput { return v.Input } + +// __StartLiveBackupCopyInput is used internally by genqlient +type __StartLiveBackupCopyInput struct { + Input *LiveBackupCopyConfigInput `json:"input,omitempty"` +} + +// GetInput returns __StartLiveBackupCopyInput.Input, and is useful for accessing the field via an interface. +func (v *__StartLiveBackupCopyInput) GetInput() *LiveBackupCopyConfigInput { return v.Input } + +// __StartMediaImportInput is used internally by genqlient +type __StartMediaImportInput struct { + Input *AppEnvironmentStartMediaImportInput `json:"input,omitempty"` +} + +// GetInput returns __StartMediaImportInput.Input, and is useful for accessing the field via an interface. +func (v *__StartMediaImportInput) GetInput() *AppEnvironmentStartMediaImportInput { return v.Input } + +// __SyncEnvironmentInput is used internally by genqlient +type __SyncEnvironmentInput struct { + Input *AppEnvironmentSyncInput `json:"input,omitempty"` +} + +// GetInput returns __SyncEnvironmentInput.Input, and is useful for accessing the field via an interface. +func (v *__SyncEnvironmentInput) GetInput() *AppEnvironmentSyncInput { return v.Input } + +// __SyncPreviewInput is used internally by genqlient +type __SyncPreviewInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __SyncPreviewInput.AppId, and is useful for accessing the field via an interface. +func (v *__SyncPreviewInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __SyncPreviewInput.EnvId, and is useful for accessing the field via an interface. +func (v *__SyncPreviewInput) GetEnvId() int64 { return v.EnvId } + +// __SyncProgressInput is used internally by genqlient +type __SyncProgressInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __SyncProgressInput.AppId, and is useful for accessing the field via an interface. +func (v *__SyncProgressInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __SyncProgressInput.EnvId, and is useful for accessing the field via an interface. +func (v *__SyncProgressInput) GetEnvId() int64 { return v.EnvId } + +// __TriggerDatabaseBackupInput is used internally by genqlient +type __TriggerDatabaseBackupInput struct { + Input *AppEnvironmentTriggerDBBackupInput `json:"input,omitempty"` +} + +// GetInput returns __TriggerDatabaseBackupInput.Input, and is useful for accessing the field via an interface. +func (v *__TriggerDatabaseBackupInput) GetInput() *AppEnvironmentTriggerDBBackupInput { return v.Input } + +// __TriggerWPCLICommandInput is used internally by genqlient +type __TriggerWPCLICommandInput struct { + Input *AppEnvironmentTriggerWPCLICommandInput `json:"input,omitempty"` +} + +// GetInput returns __TriggerWPCLICommandInput.Input, and is useful for accessing the field via an interface. +func (v *__TriggerWPCLICommandInput) GetInput() *AppEnvironmentTriggerWPCLICommandInput { + return v.Input +} + +// __UpdateDefensiveModeConfigInput is used internally by genqlient +type __UpdateDefensiveModeConfigInput struct { + Input *AppEnvironmentDefensiveModeConfigInput `json:"input,omitempty"` +} + +// GetInput returns __UpdateDefensiveModeConfigInput.Input, and is useful for accessing the field via an interface. +func (v *__UpdateDefensiveModeConfigInput) GetInput() *AppEnvironmentDefensiveModeConfigInput { + return v.Input +} + +// __UpdateDefensiveModeStatusInput is used internally by genqlient +type __UpdateDefensiveModeStatusInput struct { + Input *AppEnvironmentDefensiveModeUpdateStatusInput `json:"input,omitempty"` +} + +// GetInput returns __UpdateDefensiveModeStatusInput.Input, and is useful for accessing the field via an interface. +func (v *__UpdateDefensiveModeStatusInput) GetInput() *AppEnvironmentDefensiveModeUpdateStatusInput { + return v.Input +} + +// __UpdateSoftwareSettingsInput is used internally by genqlient +type __UpdateSoftwareSettingsInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` + Component string `json:"component"` + Version string `json:"version"` +} + +// GetAppId returns __UpdateSoftwareSettingsInput.AppId, and is useful for accessing the field via an interface. +func (v *__UpdateSoftwareSettingsInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __UpdateSoftwareSettingsInput.EnvId, and is useful for accessing the field via an interface. +func (v *__UpdateSoftwareSettingsInput) GetEnvId() int64 { return v.EnvId } + +// GetComponent returns __UpdateSoftwareSettingsInput.Component, and is useful for accessing the field via an interface. +func (v *__UpdateSoftwareSettingsInput) GetComponent() string { return v.Component } + +// GetVersion returns __UpdateSoftwareSettingsInput.Version, and is useful for accessing the field via an interface. +func (v *__UpdateSoftwareSettingsInput) GetVersion() string { return v.Version } + +// __ValidateCustomDeployAccessInput is used internally by genqlient +type __ValidateCustomDeployAccessInput struct { + Input *ValidateCustomDeployAccessInput `json:"input,omitempty"` +} + +// GetInput returns __ValidateCustomDeployAccessInput.Input, and is useful for accessing the field via an interface. +func (v *__ValidateCustomDeployAccessInput) GetInput() *ValidateCustomDeployAccessInput { + return v.Input +} + +// __WPEnvInfoInput is used internally by genqlient +type __WPEnvInfoInput struct { + AppId int64 `json:"appId"` + EnvId int64 `json:"envId"` +} + +// GetAppId returns __WPEnvInfoInput.AppId, and is useful for accessing the field via an interface. +func (v *__WPEnvInfoInput) GetAppId() int64 { return v.AppId } + +// GetEnvId returns __WPEnvInfoInput.EnvId, and is useful for accessing the field via an interface. +func (v *__WPEnvInfoInput) GetEnvId() int64 { return v.EnvId } + +// The mutation executed by AbortMediaImport. +const AbortMediaImport_Operation = ` +mutation AbortMediaImport ($input: AppEnvironmentAbortMediaImportInput) { + abortMediaImport(input: $input) { + applicationId + environmentId + mediaImportStatusChange { + importId + siteId + statusFrom + statusTo + } + } +} +` + +func AbortMediaImport( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentAbortMediaImportInput, +) (data_ *AbortMediaImportResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AbortMediaImport", + Query: AbortMediaImport_Operation, + Variables: &__AbortMediaImportInput{ + Input: input, + }, + } + + data_ = &AbortMediaImportResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by AddEnvironmentVariable. +const AddEnvironmentVariable_Operation = ` +mutation AddEnvironmentVariable ($input: EnvironmentVariableInput!) { + addEnvironmentVariable(input: $input) { + environmentVariables { + total + nodes { + name + } + } + } +} +` + +func AddEnvironmentVariable( + ctx_ context.Context, + client_ graphql.Client, + input *EnvironmentVariableInput, +) (data_ *AddEnvironmentVariableResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AddEnvironmentVariable", + Query: AddEnvironmentVariable_Operation, + Variables: &__AddEnvironmentVariableInput{ + Input: input, + }, + } + + data_ = &AddEnvironmentVariableResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by AppBackupAndJobStatus. +const AppBackupAndJobStatus_Operation = ` +query AppBackupAndJobStatus ($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + backupsSqlDumpTool + latestBackup { + id + type + size + filename + sqlDumpTool + createdAt + } + jobs(jobTypes: [db_backup_copy]) { + __typename + id + type + completedAt + createdAt + inProgressLock + metadata { + name + value + } + progress { + status + steps { + id + name + step + status + } + } + } + } + } +} +` + +func AppBackupAndJobStatus( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *AppBackupAndJobStatusResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AppBackupAndJobStatus", + Query: AppBackupAndJobStatus_Operation, + Variables: &__AppBackupAndJobStatusInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &AppBackupAndJobStatusResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by AppBackupJobStatus. +const AppBackupJobStatus_Operation = ` +query AppBackupJobStatus ($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + jobs(jobTypes: [db_backup]) { + __typename + id + type + completedAt + createdAt + inProgressLock + metadata { + name + value + } + progress { + status + } + } + } + } +} +` + +func AppBackupJobStatus( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *AppBackupJobStatusResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AppBackupJobStatus", + Query: AppBackupJobStatus_Operation, + Variables: &__AppBackupJobStatusInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &AppBackupJobStatusResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by AppGetByID. +const AppGetByID_Operation = ` +query AppGetByID ($id: Int!) { + app(id: $id) { + id + name + repo + environments { + id + appId + name + type + branch + currentCommit + primaryDomain { + name + } + launched + deploymentStrategy + } + } +} +` + +func AppGetByID( + ctx_ context.Context, + client_ graphql.Client, + id int64, +) (data_ *AppGetByIDResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AppGetByID", + Query: AppGetByID_Operation, + Variables: &__AppGetByIDInput{ + Id: id, + }, + } + + data_ = &AppGetByIDResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by AppGetByName. +const AppGetByName_Operation = ` +query AppGetByName ($name: String!) { + apps(first: 1, name: $name) { + edges { + id + name + repo + environments { + id + appId + name + type + branch + currentCommit + primaryDomain { + name + } + launched + deploymentStrategy + } + } + } +} +` + +func AppGetByName( + ctx_ context.Context, + client_ graphql.Client, + name string, +) (data_ *AppGetByNameResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AppGetByName", + Query: AppGetByName_Operation, + Variables: &__AppGetByNameInput{ + Name: name, + }, + } + + data_ = &AppGetByNameResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by AppList. +const AppList_Operation = ` +query AppList ($first: Int, $after: String) { + apps(first: $first, after: $after) { + total + nextCursor + edges { + ... AppBasic + } + } +} +fragment AppBasic on App { + id + name + repo +} +` + +func AppList( + ctx_ context.Context, + client_ graphql.Client, + first *int64, + after *string, +) (data_ *AppListResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AppList", + Query: AppList_Operation, + Variables: &__AppListInput{ + First: first, + After: after, + }, + } + + data_ = &AppListResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by AppMappedDomains. +const AppMappedDomains_Operation = ` +query AppMappedDomains ($appId: Int, $envId: Int) { + app(id: $appId) { + id + name + environments(id: $envId) { + uniqueLabel + isMultisite + domains { + nodes { + name + isPrimary + } + } + } + } +} +` + +func AppMappedDomains( + ctx_ context.Context, + client_ graphql.Client, + appId *int64, + envId *int64, +) (data_ *AppMappedDomainsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AppMappedDomains", + Query: AppMappedDomains_Operation, + Variables: &__AppMappedDomainsInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &AppMappedDomainsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by AppMultiSiteCheck. +const AppMultiSiteCheck_Operation = ` +query AppMultiSiteCheck ($appId: Int, $envId: Int) { + app(id: $appId) { + id + name + repo + environments(id: $envId) { + id + appId + name + type + isMultisite + isSubdirectoryMultisite + } + } +} +` + +func AppMultiSiteCheck( + ctx_ context.Context, + client_ graphql.Client, + appId *int64, + envId *int64, +) (data_ *AppMultiSiteCheckResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AppMultiSiteCheck", + Query: AppMultiSiteCheck_Operation, + Variables: &__AppMultiSiteCheckInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &AppMultiSiteCheckResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by BackupDBCopy. +const BackupDBCopy_Operation = ` +mutation BackupDBCopy ($input: AppEnvironmentStartDBBackupCopyInput) { + startDBBackupCopy(input: $input) { + message + success + } +} +` + +func BackupDBCopy( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentStartDBBackupCopyInput, +) (data_ *BackupDBCopyResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "BackupDBCopy", + Query: BackupDBCopy_Operation, + Variables: &__BackupDBCopyInput{ + Input: input, + }, + } + + data_ = &BackupDBCopyResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by DeleteEnvironmentVariable. +const DeleteEnvironmentVariable_Operation = ` +mutation DeleteEnvironmentVariable ($input: EnvironmentVariableInput!) { + deleteEnvironmentVariable(input: $input) { + environmentVariables { + total + nodes { + name + } + } + } +} +` + +func DeleteEnvironmentVariable( + ctx_ context.Context, + client_ graphql.Client, + input *EnvironmentVariableInput, +) (data_ *DeleteEnvironmentVariableResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "DeleteEnvironmentVariable", + Query: DeleteEnvironmentVariable_Operation, + Variables: &__DeleteEnvironmentVariableInput{ + Input: input, + }, + } + + data_ = &DeleteEnvironmentVariableResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by DevEnvAppInfo. +const DevEnvAppInfo_Operation = ` +query DevEnvAppInfo ($appId: Int!) { + app(id: $appId) { + id + name + environments { + id + appId + name + type + isMultisite + primaryDomain { + name + } + environmentVariables { + nodes { + name + } + } + softwareSettings { + php { + current { + version + } + } + wordpress { + current { + version + } + } + } + } + } +} +` + +// dev-env create @app.env pre-population. Node source: +// getApplicationInformation — src/lib/dev-environment/dev-environment-core.ts:735 +// getOptionsFromAppInfo — src/lib/dev-environment/dev-environment-cli.ts:257 +// Fetches all environments (no useful server-side filter; the env is picked +// client-side by type) with the fields that seed the wizard defaults. +func DevEnvAppInfo( + ctx_ context.Context, + client_ graphql.Client, + appId int64, +) (data_ *DevEnvAppInfoResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "DevEnvAppInfo", + Query: DevEnvAppInfo_Operation, + Variables: &__DevEnvAppInfoInput{ + AppId: appId, + }, + } + + data_ = &DevEnvAppInfoResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by DevEnvSyncSites. +const DevEnvSyncSites_Operation = ` +query DevEnvSyncSites ($appId: Int!, $environmentId: Int!, $after: String, $first: Int!) { + app(id: $appId) { + environments(id: $environmentId) { + wpSitesSDS(after: $after, first: $first) { + total + nextCursor + nodes { + blogId + homeUrl + siteUrl + } + } + } + } +} +` + +func DevEnvSyncSites( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + environmentId int64, + after *string, + first int64, +) (data_ *DevEnvSyncSitesResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "DevEnvSyncSites", + Query: DevEnvSyncSites_Operation, + Variables: &__DevEnvSyncSitesInput{ + AppId: appId, + EnvironmentId: environmentId, + After: after, + First: first, + }, + } + + data_ = &DevEnvSyncSitesResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by EnablePhpMyAdmin. +const EnablePhpMyAdmin_Operation = ` +mutation EnablePhpMyAdmin ($input: EnablePhpMyAdminInput!) { + enablePHPMyAdmin(input: $input) { + success + } +} +` + +func EnablePhpMyAdmin( + ctx_ context.Context, + client_ graphql.Client, + input *EnablePhpMyAdminInput, +) (data_ *EnablePhpMyAdminResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "EnablePhpMyAdmin", + Query: EnablePhpMyAdmin_Operation, + Variables: &__EnablePhpMyAdminInput{ + Input: input, + }, + } + + data_ = &EnablePhpMyAdminResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by GenerateDBBackupCopyUrl. +const GenerateDBBackupCopyUrl_Operation = ` +mutation GenerateDBBackupCopyUrl ($input: AppEnvironmentGenerateDBBackupCopyUrlInput) { + generateDBBackupCopyUrl(input: $input) { + url + success + } +} +` + +func GenerateDBBackupCopyUrl( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentGenerateDBBackupCopyUrlInput, +) (data_ *GenerateDBBackupCopyUrlResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GenerateDBBackupCopyUrl", + Query: GenerateDBBackupCopyUrl_Operation, + Variables: &__GenerateDBBackupCopyUrlInput{ + Input: input, + }, + } + + data_ = &GenerateDBBackupCopyUrlResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by GenerateLiveBackupCopyDownloadURL. +const GenerateLiveBackupCopyDownloadURL_Operation = ` +mutation GenerateLiveBackupCopyDownloadURL ($input: AppEnvironmentLiveBackupCopyDownloadURLInput!) { + generateLiveBackupCopyDownloadURL(input: $input) { + success + url + processing + size + } +} +` + +func GenerateLiveBackupCopyDownloadURL( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentLiveBackupCopyDownloadURLInput, +) (data_ *GenerateLiveBackupCopyDownloadURLResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GenerateLiveBackupCopyDownloadURL", + Query: GenerateLiveBackupCopyDownloadURL_Operation, + Variables: &__GenerateLiveBackupCopyDownloadURLInput{ + Input: input, + }, + } + + data_ = &GenerateLiveBackupCopyDownloadURLResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by GeneratePhpMyAdminAccess. +const GeneratePhpMyAdminAccess_Operation = ` +mutation GeneratePhpMyAdminAccess ($input: GeneratePhpMyAdminAccessInput!) { + generatePHPMyAdminAccess(input: $input) { + url + } +} +` + +func GeneratePhpMyAdminAccess( + ctx_ context.Context, + client_ graphql.Client, + input *GeneratePhpMyAdminAccessInput, +) (data_ *GeneratePhpMyAdminAccessResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GeneratePhpMyAdminAccess", + Query: GeneratePhpMyAdminAccess_Operation, + Variables: &__GeneratePhpMyAdminAccessInput{ + Input: input, + }, + } + + data_ = &GeneratePhpMyAdminAccessResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by GetAppLogs. +const GetAppLogs_Operation = ` +query GetAppLogs ($appId: Int!, $envId: Int!, $logType: AppEnvironmentLogType!, $limit: Int!, $after: String) { + app(id: $appId) { + id + environments(id: $envId) { + id + logs(type: $logType, limit: $limit, after: $after) { + nodes { + timestamp + message + } + nextCursor + pollingDelaySeconds + } + } + } +} +` + +func GetAppLogs( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, + logType AppEnvironmentLogType, + limit int64, + after *string, +) (data_ *GetAppLogsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetAppLogs", + Query: GetAppLogs_Operation, + Variables: &__GetAppLogsInput{ + AppId: appId, + EnvId: envId, + LogType: logType, + Limit: limit, + After: after, + }, + } + + data_ = &GetAppLogsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by GetAppSlowlogs. +const GetAppSlowlogs_Operation = ` +query GetAppSlowlogs ($appId: Int!, $envId: Int!, $limit: Int!, $after: String) { + app(id: $appId) { + id + environments(id: $envId) { + id + slowlogs(limit: $limit, after: $after) { + nodes { + timestamp + rowsSent + rowsExamined + queryTime + requestUri + query + } + nextCursor + pollingDelaySeconds + } + } + } +} +` + +func GetAppSlowlogs( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, + limit int64, + after *string, +) (data_ *GetAppSlowlogsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetAppSlowlogs", + Query: GetAppSlowlogs_Operation, + Variables: &__GetAppSlowlogsInput{ + AppId: appId, + EnvId: envId, + Limit: limit, + After: after, + }, + } + + data_ = &GetAppSlowlogsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by GetEnvironmentVariables. +const GetEnvironmentVariables_Operation = ` +query GetEnvironmentVariables ($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + environmentVariables { + total + nodes { + name + } + } + } + } +} +` + +func GetEnvironmentVariables( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *GetEnvironmentVariablesResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetEnvironmentVariables", + Query: GetEnvironmentVariables_Operation, + Variables: &__GetEnvironmentVariablesInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &GetEnvironmentVariablesResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by GetEnvironmentVariablesWithValues. +const GetEnvironmentVariablesWithValues_Operation = ` +query GetEnvironmentVariablesWithValues ($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + environmentVariables { + total + nodes { + name + value + } + } + } + } +} +` + +func GetEnvironmentVariablesWithValues( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *GetEnvironmentVariablesWithValuesResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "GetEnvironmentVariablesWithValues", + Query: GetEnvironmentVariablesWithValues_Operation, + Variables: &__GetEnvironmentVariablesWithValuesInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &GetEnvironmentVariablesWithValuesResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by ImportSQLEnvInfo. +const ImportSQLEnvInfo_Operation = ` +query ImportSQLEnvInfo ($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + name + typeId + environments(id: $envId) { + id + appId + type + name + launched + isK8sResident + primaryDomain { + name + } + importStatus { + dbOperationInProgress + importInProgress + } + wpSitesSDS { + nodes { + homeUrl + id + } + } + } + } +} +` + +func ImportSQLEnvInfo( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *ImportSQLEnvInfoResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ImportSQLEnvInfo", + Query: ImportSQLEnvInfo_Operation, + Variables: &__ImportSQLEnvInfoInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &ImportSQLEnvInfoResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by ImportSQLProgress. +const ImportSQLProgress_Operation = ` +query ImportSQLProgress ($appId: Int, $envId: Int) { + app(id: $appId) { + environments(id: $envId) { + id + isK8sResident + launched + jobs(types: ["sql_import"]) { + __typename + id + type + completedAt + createdAt + progress { + status + steps { + id + name + status + } + } + } + importStatus { + dbOperationInProgress + importInProgress + progress { + started_at + steps { + name + started_at + finished_at + result + output + } + finished_at + } + } + } + } +} +` + +func ImportSQLProgress( + ctx_ context.Context, + client_ graphql.Client, + appId *int64, + envId *int64, +) (data_ *ImportSQLProgressResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ImportSQLProgress", + Query: ImportSQLProgress_Operation, + Variables: &__ImportSQLProgressInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &ImportSQLProgressResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by Me. +const Me_Operation = ` +query Me { + me { + id + displayName + isVIP + organizationRoles { + nodes { + organizationId + roleId + } + } + } +} +` + +func Me( + ctx_ context.Context, + client_ graphql.Client, +) (data_ *MeResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "Me", + Query: Me_Operation, + } + + data_ = &MeResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by MediaImportConfig. +const MediaImportConfig_Operation = ` +query MediaImportConfig { + mediaImportConfig { + fileNameCharCount + fileSizeLimitInBytes + allowedFileTypes + } +} +` + +func MediaImportConfig( + ctx_ context.Context, + client_ graphql.Client, +) (data_ *MediaImportConfigResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "MediaImportConfig", + Query: MediaImportConfig_Operation, + } + + data_ = &MediaImportConfigResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by MediaImportProgress. +const MediaImportProgress_Operation = ` +query MediaImportProgress ($appId: Int, $envId: Int) { + app(id: $appId) { + environments(id: $envId) { + id + name + type + repo + mediaImportStatus { + importId + siteId + status + filesTotal + filesProcessed + failureDetails { + previousStatus + globalErrors + fileErrorsUrl + } + } + } + } +} +` + +func MediaImportProgress( + ctx_ context.Context, + client_ graphql.Client, + appId *int64, + envId *int64, +) (data_ *MediaImportProgressResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "MediaImportProgress", + Query: MediaImportProgress_Operation, + Variables: &__MediaImportProgressInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &MediaImportProgressResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by PhpMyAdminStatus. +const PhpMyAdminStatus_Operation = ` +query PhpMyAdminStatus ($appId: Int!, $envId: Int!) { + app(id: $appId) { + environments(id: $envId) { + phpMyAdminStatus { + status + } + } + } +} +` + +func PhpMyAdminStatus( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *PhpMyAdminStatusResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "PhpMyAdminStatus", + Query: PhpMyAdminStatus_Operation, + Variables: &__PhpMyAdminStatusInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &PhpMyAdminStatusResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by PurgePageCache. +const PurgePageCache_Operation = ` +mutation PurgePageCache ($input: PurgePageCacheInput!) { + purgePageCache(input: $input) { + success + urls + } +} +` + +func PurgePageCache( + ctx_ context.Context, + client_ graphql.Client, + input *PurgePageCacheInput, +) (data_ *PurgePageCacheResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "PurgePageCache", + Query: PurgePageCache_Operation, + Variables: &__PurgePageCacheInput{ + Input: input, + }, + } + + data_ = &PurgePageCacheResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by ResolveAppByID. +const ResolveAppByID_Operation = ` +query ResolveAppByID ($id: Int!) { + app(id: $id) { + id + name + type + typeId + environments { + id + appId + name + type + uniqueLabel + defaultDomain + isMultisite + } + } +} +` + +func ResolveAppByID( + ctx_ context.Context, + client_ graphql.Client, + id int64, +) (data_ *ResolveAppByIDResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ResolveAppByID", + Query: ResolveAppByID_Operation, + Variables: &__ResolveAppByIDInput{ + Id: id, + }, + } + + data_ = &ResolveAppByIDResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by ResolveAppByName. +const ResolveAppByName_Operation = ` +query ResolveAppByName ($name: String!) { + apps(first: 1, name: $name) { + edges { + id + name + type + typeId + environments { + id + appId + name + type + uniqueLabel + defaultDomain + isMultisite + } + } + } +} +` + +func ResolveAppByName( + ctx_ context.Context, + client_ graphql.Client, + name string, +) (data_ *ResolveAppByNameResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ResolveAppByName", + Query: ResolveAppByName_Operation, + Variables: &__ResolveAppByNameInput{ + Name: name, + }, + } + + data_ = &ResolveAppByNameResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by SoftwareSettings. +const SoftwareSettings_Operation = ` +query SoftwareSettings ($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + name + typeId + environments(id: $envId) { + id + appId + type + name + softwareSettings { + wordpress { + ... SoftwareNode + } + php { + ... SoftwareNode + } + muplugins { + ... SoftwareNode + } + nodejs { + ... SoftwareNode + } + } + } + } +} +fragment SoftwareNode on AppEnvironmentSoftwareSettingsSoftware { + name + slug + pinned + current { + version + default + deprecated + unstable + compatible + latestRelease + private + } + options { + version + default + deprecated + unstable + compatible + latestRelease + private + } +} +` + +func SoftwareSettings( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *SoftwareSettingsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "SoftwareSettings", + Query: SoftwareSettings_Operation, + Variables: &__SoftwareSettingsInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &SoftwareSettingsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by SoftwareUpdateJob. +const SoftwareUpdateJob_Operation = ` +query SoftwareUpdateJob ($appId: Int!, $envId: Int!) { + app(id: $appId) { + environments(id: $envId) { + jobs(types: ["upgrade_php","upgrade_wordpress","upgrade_muplugins","upgrade_nodejs"]) { + __typename + type + completedAt + createdAt + inProgressLock + progress { + status + steps { + step + name + status + } + } + } + } + } +} +` + +func SoftwareUpdateJob( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *SoftwareUpdateJobResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "SoftwareUpdateJob", + Query: SoftwareUpdateJob_Operation, + Variables: &__SoftwareUpdateJobInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &SoftwareUpdateJobResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by StartCustomDeploy. +const StartCustomDeploy_Operation = ` +mutation StartCustomDeploy ($input: AppEnvironmentCustomDeployInput) { + startCustomDeploy(input: $input) { + success + message + } +} +` + +func StartCustomDeploy( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentCustomDeployInput, +) (data_ *StartCustomDeployResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "StartCustomDeploy", + Query: StartCustomDeploy_Operation, + Variables: &__StartCustomDeployInput{ + Input: input, + }, + } + + data_ = &StartCustomDeployResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by StartImport. +const StartImport_Operation = ` +mutation StartImport ($input: AppEnvironmentImportInput) { + startImport(input: $input) { + app { + id + name + } + message + success + } +} +` + +// The startImport server resolver calls input.searchReplace.filter(...) and +// expects urlHeaders to be present, so empty arrays must be sent as [] rather +// than omitted. Disable genqlient's default omitempty on these list fields to +// match the Node CLI (which always sends searchReplace: []). The $input variable +// is on its own line so the for-directives attach to the operation, not $input. +// +// `--search-replace="a"` (no comma) leaves arr[1] undefined in Node +// (vip-import-sql.js:821-827), and JSON.stringify drops undefined properties, +// so the pair goes over the wire as {from:"a"} with NO `to` key. Sending +// to:"" instead means "replace every occurrence of a with nothing" — silent +// data destruction. omitempty lets a nil *string reproduce Node's omission; +// a non-nil pointer to "" (from a trailing comma, "a,") still serializes. +func StartImport( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentImportInput, +) (data_ *StartImportResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "StartImport", + Query: StartImport_Operation, + Variables: &__StartImportInput{ + Input: input, + }, + } + + data_ = &StartImportResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by StartLiveBackupCopy. +const StartLiveBackupCopy_Operation = ` +mutation StartLiveBackupCopy ($input: LiveBackupCopyConfigInput!) { + startLiveBackupCopy(input: $input) { + message + copyId + } +} +` + +func StartLiveBackupCopy( + ctx_ context.Context, + client_ graphql.Client, + input *LiveBackupCopyConfigInput, +) (data_ *StartLiveBackupCopyResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "StartLiveBackupCopy", + Query: StartLiveBackupCopy_Operation, + Variables: &__StartLiveBackupCopyInput{ + Input: input, + }, + } + + data_ = &StartLiveBackupCopyResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by StartMediaImport. +const StartMediaImport_Operation = ` +mutation StartMediaImport ($input: AppEnvironmentStartMediaImportInput) { + startMediaImport(input: $input) { + applicationId + environmentId + mediaImportStatus { + importId + siteId + status + } + } +} +` + +func StartMediaImport( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentStartMediaImportInput, +) (data_ *StartMediaImportResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "StartMediaImport", + Query: StartMediaImport_Operation, + Variables: &__StartMediaImportInput{ + Input: input, + }, + } + + data_ = &StartMediaImportResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by SyncEnvironment. +const SyncEnvironment_Operation = ` +mutation SyncEnvironment ($input: AppEnvironmentSyncInput!) { + syncEnvironment(input: $input) { + environment { + id + } + } +} +` + +func SyncEnvironment( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentSyncInput, +) (data_ *SyncEnvironmentResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "SyncEnvironment", + Query: SyncEnvironment_Operation, + Variables: &__SyncEnvironmentInput{ + Input: input, + }, + } + + data_ = &SyncEnvironmentResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by SyncPreview. +const SyncPreview_Operation = ` +query SyncPreview ($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + syncPreview { + canSync + errors { + message + } + backup { + createdAt + } + replacements { + from + to + } + } + } + } +} +` + +// The pre-flight Node runs before the sync mutation. Node folds these +// fields into vip-sync.js's appQuery; vip-next resolves app/env through a +// shared query, so the preview is fetched separately by the confirmation +// payload (src/lib/cli/command.js:913-933). +func SyncPreview( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *SyncPreviewResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "SyncPreview", + Query: SyncPreview_Operation, + Variables: &__SyncPreviewInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &SyncPreviewResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by SyncProgress. +const SyncProgress_Operation = ` +query SyncProgress ($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + environments(id: $envId) { + id + syncProgress { + status + sync + steps { + name + status + step + } + } + } + } +} +` + +func SyncProgress( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *SyncProgressResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "SyncProgress", + Query: SyncProgress_Operation, + Variables: &__SyncProgressInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &SyncProgressResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by TriggerDatabaseBackup. +const TriggerDatabaseBackup_Operation = ` +mutation TriggerDatabaseBackup ($input: AppEnvironmentTriggerDBBackupInput) { + triggerDatabaseBackup(input: $input) { + success + } +} +` + +func TriggerDatabaseBackup( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentTriggerDBBackupInput, +) (data_ *TriggerDatabaseBackupResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "TriggerDatabaseBackup", + Query: TriggerDatabaseBackup_Operation, + Variables: &__TriggerDatabaseBackupInput{ + Input: input, + }, + } + + data_ = &TriggerDatabaseBackupResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by TriggerWPCLICommand. +const TriggerWPCLICommand_Operation = ` +mutation TriggerWPCLICommand ($input: AppEnvironmentTriggerWPCLICommandInput) { + triggerWPCLICommandOnAppEnvironment(input: $input) { + inputToken + command { + guid + } + sshAuthentication { + host + port + username + privateKey + passphrase + } + } +} +` + +func TriggerWPCLICommand( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentTriggerWPCLICommandInput, +) (data_ *TriggerWPCLICommandResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "TriggerWPCLICommand", + Query: TriggerWPCLICommand_Operation, + Variables: &__TriggerWPCLICommandInput{ + Input: input, + }, + } + + data_ = &TriggerWPCLICommandResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by UpdateDefensiveModeConfig. +const UpdateDefensiveModeConfig_Operation = ` +mutation UpdateDefensiveModeConfig ($input: AppEnvironmentDefensiveModeConfigInput!) { + updateDefensiveModeConfig(input: $input) { + success + message + } +} +` + +func UpdateDefensiveModeConfig( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentDefensiveModeConfigInput, +) (data_ *UpdateDefensiveModeConfigResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "UpdateDefensiveModeConfig", + Query: UpdateDefensiveModeConfig_Operation, + Variables: &__UpdateDefensiveModeConfigInput{ + Input: input, + }, + } + + data_ = &UpdateDefensiveModeConfigResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by UpdateDefensiveModeStatus. +const UpdateDefensiveModeStatus_Operation = ` +mutation UpdateDefensiveModeStatus ($input: AppEnvironmentDefensiveModeUpdateStatusInput!) { + updateDefensiveModeStatus(input: $input) { + success + message + } +} +` + +func UpdateDefensiveModeStatus( + ctx_ context.Context, + client_ graphql.Client, + input *AppEnvironmentDefensiveModeUpdateStatusInput, +) (data_ *UpdateDefensiveModeStatusResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "UpdateDefensiveModeStatus", + Query: UpdateDefensiveModeStatus_Operation, + Variables: &__UpdateDefensiveModeStatusInput{ + Input: input, + }, + } + + data_ = &UpdateDefensiveModeStatusResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by UpdateSoftwareSettings. +const UpdateSoftwareSettings_Operation = ` +mutation UpdateSoftwareSettings ($appId: Int!, $envId: Int!, $component: String!, $version: String!) { + updateSoftwareSettings(input: {appId:$appId,environmentId:$envId,softwareName:$component,softwareVersion:$version}) { + wordpress { + ... SoftwareNode + } + php { + ... SoftwareNode + } + muplugins { + ... SoftwareNode + } + nodejs { + ... SoftwareNode + } + } +} +fragment SoftwareNode on AppEnvironmentSoftwareSettingsSoftware { + name + slug + pinned + current { + version + default + deprecated + unstable + compatible + latestRelease + private + } + options { + version + default + deprecated + unstable + compatible + latestRelease + private + } +} +` + +func UpdateSoftwareSettings( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, + component string, + version string, +) (data_ *UpdateSoftwareSettingsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "UpdateSoftwareSettings", + Query: UpdateSoftwareSettings_Operation, + Variables: &__UpdateSoftwareSettingsInput{ + AppId: appId, + EnvId: envId, + Component: component, + Version: version, + }, + } + + data_ = &UpdateSoftwareSettingsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The mutation executed by ValidateCustomDeployAccess. +const ValidateCustomDeployAccess_Operation = ` +mutation ValidateCustomDeployAccess ($input: ValidateCustomDeployAccessInput!) { + validateCustomDeployAccess(input: $input) { + success + appId + envId + envType + envUniqueLabel + primaryDomainName + launched + } +} +` + +func ValidateCustomDeployAccess( + ctx_ context.Context, + client_ graphql.Client, + input *ValidateCustomDeployAccessInput, +) (data_ *ValidateCustomDeployAccessResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ValidateCustomDeployAccess", + Query: ValidateCustomDeployAccess_Operation, + Variables: &__ValidateCustomDeployAccessInput{ + Input: input, + }, + } + + data_ = &ValidateCustomDeployAccessResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by WPEnvInfo. +const WPEnvInfo_Operation = ` +query WPEnvInfo ($appId: Int!, $envId: Int!) { + app(id: $appId) { + id + name + typeId + environments(id: $envId) { + id + appId + type + name + wpcliStrategy + primaryDomain { + name + } + } + } +} +` + +func WPEnvInfo( + ctx_ context.Context, + client_ graphql.Client, + appId int64, + envId int64, +) (data_ *WPEnvInfoResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "WPEnvInfo", + Query: WPEnvInfo_Operation, + Variables: &__WPEnvInfoInput{ + AppId: appId, + EnvId: envId, + }, + } + + data_ = &WPEnvInfoResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} From 42b8c4b4ce2f60b8018a6bab186b3c606f2b9364 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 20 Aug 2026 18:36:31 -0500 Subject: [PATCH 06/32] feat(go): GraphQL client, transport and retry The hand-written half of internal/gql: request execution, HTTP transport, retry and backoff, error shaping, and the rechallenge hook that drives step-up auth when the API demands it. Ported from vip-cli-golang@72ef2f89 (feature/go-rewrite). --- internal/gql/client.go | 54 +++ internal/gql/client_test.go | 71 +++ internal/gql/decoder_compat_helpers_test.go | 35 ++ internal/gql/decoder_compat_test.go | 78 ++++ internal/gql/doc.go | 13 + internal/gql/error.go | 137 ++++++ internal/gql/error_test.go | 203 +++++++++ internal/gql/import_sql_marshal_test.go | 69 +++ internal/gql/operation.go | 67 +++ internal/gql/operation_test.go | 60 +++ internal/gql/proxy_test.go | 62 +++ internal/gql/rechallenge.go | 214 +++++++++ internal/gql/rechallenge_test.go | 471 ++++++++++++++++++++ internal/gql/retry.go | 111 +++++ internal/gql/retry_test.go | 230 ++++++++++ internal/gql/transport.go | 56 +++ internal/gql/transport_helper.go | 24 + 17 files changed, 1955 insertions(+) create mode 100644 internal/gql/client.go create mode 100644 internal/gql/client_test.go create mode 100644 internal/gql/decoder_compat_helpers_test.go create mode 100644 internal/gql/decoder_compat_test.go create mode 100644 internal/gql/doc.go create mode 100644 internal/gql/error.go create mode 100644 internal/gql/error_test.go create mode 100644 internal/gql/import_sql_marshal_test.go create mode 100644 internal/gql/operation.go create mode 100644 internal/gql/operation_test.go create mode 100644 internal/gql/proxy_test.go create mode 100644 internal/gql/rechallenge.go create mode 100644 internal/gql/rechallenge_test.go create mode 100644 internal/gql/retry.go create mode 100644 internal/gql/retry_test.go create mode 100644 internal/gql/transport.go create mode 100644 internal/gql/transport_helper.go diff --git a/internal/gql/client.go b/internal/gql/client.go new file mode 100644 index 000000000..4d6557007 --- /dev/null +++ b/internal/gql/client.go @@ -0,0 +1,54 @@ +package gql + +import ( + "net/http" + + "github.com/Automattic/vip/internal/httpproxy" +) + +// Doer mirrors genqlient's interface plus what middleware needs. +type Doer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Middleware wraps a Doer with a new Doer. +type Middleware func(next Doer) Doer + +// Config selects the GraphQL endpoint and per-environment behaviors. +type Config struct { + APIHost string // e.g. "https://api.wpvip.com" + TestMode bool // if true, skip the x_query rewrite (matches NODE_ENV=test) + Token string // bearer token (set by callers; auth package supplies) + HTTPClient *http.Client + Middleware []Middleware // outermost first + ExitOnError bool // honored by Error middleware (Task 5) + SilenceAuth bool // honored by Error middleware (Task 5) +} + +// Client composes a transport with middleware. It implements Doer. +type Client struct { + chain Doer + cfg Config +} + +func NewClient(cfg Config) *Client { + if cfg.HTTPClient == nil { + // NOT http.DefaultClient: its proxy policy is the inverse of Node's + // (see internal/httpproxy). This client carries the bearer token. + cfg.HTTPClient = httpproxy.Client() + } + base := newTransport(cfg) + chain := Doer(base) + for i := len(cfg.Middleware) - 1; i >= 0; i-- { + chain = cfg.Middleware[i](chain) + } + return &Client{chain: chain, cfg: cfg} +} + +func (c *Client) Do(req *http.Request) (*http.Response, error) { + return c.chain.Do(req) +} + +// APIHost returns the configured API host. Used by callers (e.g. defensivemode) +// that need to POST directly to /graphql. +func (c *Client) APIHost() string { return c.cfg.APIHost } diff --git a/internal/gql/client_test.go b/internal/gql/client_test.go new file mode 100644 index 000000000..8636e59c8 --- /dev/null +++ b/internal/gql/client_test.go @@ -0,0 +1,71 @@ +package gql + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestClientPostsToGraphQLEndpointWithXQuery(t *testing.T) { + var got struct { + path string + query string + body string + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got.path = r.URL.Path + got.query = r.URL.RawQuery + b := make([]byte, 4096) + n, _ := r.Body.Read(b) + got.body = string(b[:n]) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"data":{"me":null}}`)) + })) + defer srv.Close() + + c := NewClient(Config{APIHost: srv.URL}) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"query":"query Me{me{id}}","operationName":"Me"}`)) + req.Header.Set("Content-Type", "application/json") + if _, err := c.Do(req); err != nil { + t.Fatalf("Do: %v", err) + } + if got.path != "/graphql" { + t.Errorf("path = %q, want /graphql", got.path) + } + if !strings.HasPrefix(got.query, "x_query=Me") { + t.Errorf("query = %q, want prefix x_query=Me", got.query) + } +} + +func TestClientSkipsXQueryInTestEnv(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != "" { + t.Errorf("test-mode client must not append x_query; got %q", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"data":{}}`)) + })) + defer srv.Close() + + c := NewClient(Config{APIHost: srv.URL, TestMode: true}) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"query":"{me{id}}","operationName":"Me"}`)) + if _, err := c.Do(req); err != nil { + t.Fatalf("Do: %v", err) + } +} + +func TestClientSetsAuthHeaderWhenTokenPresent(t *testing.T) { + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("Authorization") + w.Write([]byte(`{"data":{}}`)) + })) + defer srv.Close() + c := NewClient(Config{APIHost: srv.URL, TestMode: true, Token: "abc.def.ghi"}) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"{me{id}}"}`)) + c.Do(req) + if got != "Bearer abc.def.ghi" { + t.Errorf("Authorization = %q, want Bearer abc.def.ghi", got) + } +} diff --git a/internal/gql/decoder_compat_helpers_test.go b/internal/gql/decoder_compat_helpers_test.go new file mode 100644 index 000000000..76d13da86 --- /dev/null +++ b/internal/gql/decoder_compat_helpers_test.go @@ -0,0 +1,35 @@ +// Package-level helpers shared by decoder_compat_test.go. +package gql + +import ( + "fmt" + "reflect" +) + +// reflectedField is a tiny shape used by the forward-compat audit test. +type reflectedField struct { + Name string + Type string +} + +// reflectExportedFields enumerates exported fields of v (which may be a +// struct value or pointer to one). Used to enforce the "all generated +// optional fields must be pointers" invariant from genqlient.yaml. +func reflectExportedFields(v any) []reflectedField { + t := reflect.TypeOf(v) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil + } + out := make([]reflectedField, 0, t.NumField()) + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !f.IsExported() { + continue + } + out = append(out, reflectedField{Name: f.Name, Type: fmt.Sprintf("%v", f.Type)}) + } + return out +} diff --git a/internal/gql/decoder_compat_test.go b/internal/gql/decoder_compat_test.go new file mode 100644 index 000000000..9a598e7d2 --- /dev/null +++ b/internal/gql/decoder_compat_test.go @@ -0,0 +1,78 @@ +package gql + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Khan/genqlient/graphql" + + json "encoding/json/v2" +) + +func TestDecodeIgnoresUnknownTopLevelFields(t *testing.T) { + type known struct { + ID *int64 `json:"id"` + Name *string `json:"name"` + } + payload := []byte(`{"id":42,"name":"x","newField":"surprise","anotherNew":{"nested":1}}`) + var k known + if err := json.Unmarshal(payload, &k); err != nil { + t.Fatalf("decode failed on extra fields — forward-compat lost: %v", err) + } + if k.ID == nil || *k.ID != 42 || k.Name == nil || *k.Name != "x" { + t.Errorf("decoded values wrong: %+v", k) + } +} + +func TestDecodeAcceptsNullForOptionalFields(t *testing.T) { + type opt struct { + Name *string `json:"name"` + } + var o opt + if err := json.Unmarshal([]byte(`{"name":null}`), &o); err != nil { + t.Fatalf("decode failed on null: %v", err) + } + if o.Name != nil { + t.Errorf("null should yield nil pointer; got %v", *o.Name) + } +} + +func TestGenqlientResponseIgnoresUnknownFields(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"data":{"me":{"id":7,"displayName":"x","isVIP":true,"newServerOnlyField":"surprise"}}}`)) + })) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", http.DefaultClient) + res, err := Me(t.Context(), c) + if err != nil { + t.Fatalf("Me: %v", err) + } + if res.Me == nil || res.Me.Id == nil || *res.Me.Id != 7 { + t.Errorf("decoded Me wrong: %+v", res.Me) + } +} + +func TestAuditNoNonPointerOptionals(t *testing.T) { + type checked struct { + typ string + read func() any + } + cases := []checked{ + {typ: "MeMe", read: func() any { return MeMe{} }}, + } + for _, c := range cases { + v := c.read() + fields := reflectExportedFields(v) + for _, f := range fields { + if strings.HasPrefix(f.Name, "GetType") || f.Name == "Typename" { + continue + } + if !strings.HasPrefix(f.Type, "*") && !strings.HasPrefix(f.Type, "[]") { + t.Errorf("%s.%s is %s (expected pointer/slice for forward-compat tolerance)", c.typ, f.Name, f.Type) + } + } + } +} diff --git a/internal/gql/doc.go b/internal/gql/doc.go new file mode 100644 index 000000000..4d8908904 --- /dev/null +++ b/internal/gql/doc.go @@ -0,0 +1,13 @@ +// Package gql is the typed GraphQL client for the vip-cli Go rewrite. +// +// The schema is vendored from the Node project (see SCHEMA.md). Operations +// live in operations/*.graphql. Run `go generate ./internal/gql/...` to +// regenerate the typed client into generated.go. +// +// The client is composed of stacked middleware (transport -> retry -> +// rechallenge -> error-handling, applied outermost-first). Each middleware +// is a Doer that wraps a next Doer. The rechallenge slot is a no-op in +// M2; M3 will fill it in with the step-up flow per project_rechallenge_v2.md. +package gql + +//go:generate genqlient diff --git a/internal/gql/error.go b/internal/gql/error.go new file mode 100644 index 000000000..6121849ea --- /dev/null +++ b/internal/gql/error.go @@ -0,0 +1,137 @@ +package gql + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "os" + + json "encoding/json/v2" +) + +// ErrorConfig controls the behavior of the error middleware. +type ErrorConfig struct { + Stderr io.Writer + Exit func(int) + Silence bool // mirrors silenceAuthErrors + ExitOnError bool // mirrors exitOnError +} + +// ctxKeyAllowGQLErrors is a context key that, when set to a true bool, +// instructs the error middleware to skip its print + exit behavior for +// the request's response so the caller can inspect the GraphQL errors +// inline (e.g. vip sync handles "Site is already syncing" specially). +type ctxKeyAllowGQLErrors struct{} + +// WithAllowGQLErrors returns a child context that disables the error +// middleware's print + exit-on-error behavior for any GraphQL request +// issued with this context (or one derived from it). Network/401 paths +// are unaffected — only the GraphQL errors[] check is bypassed. +func WithAllowGQLErrors(ctx context.Context) context.Context { + return context.WithValue(ctx, ctxKeyAllowGQLErrors{}, true) +} + +// allowGQLErrorsFromContext reports whether the request's context opts +// out of the error middleware's GraphQL-error handling. +func allowGQLErrorsFromContext(ctx context.Context) bool { + if ctx == nil { + return false + } + v, _ := ctx.Value(ctxKeyAllowGQLErrors{}).(bool) + return v +} + +// NewErrorMiddleware returns a Middleware that: +// - On HTTP 401 (and !Silence): prints "Unauthorized: " to Stderr and calls Exit(1). +// - On GraphQL errors: prints "Error: " for each error; calls Exit(1) if ExitOnError. +// +// Message wording is exact Node parity with src/lib/api.ts errorLink. +func NewErrorMiddleware(cfg ErrorConfig) Middleware { + if cfg.Stderr == nil { + cfg.Stderr = os.Stderr + } + if cfg.Exit == nil { + cfg.Exit = os.Exit + } + return func(next Doer) Doer { return &errorDoer{next: next, cfg: cfg} } +} + +type errorDoer struct { + next Doer + cfg ErrorConfig +} + +func (e *errorDoer) Do(req *http.Request) (*http.Response, error) { + resp, err := e.next.Do(req) + if err != nil || resp == nil { + return resp, err + } + + if resp.StatusCode == 401 && !e.cfg.Silence { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + msg := decode401Message(body) + fmt.Fprintf(e.cfg.Stderr, "Unauthorized: %s\n", msg) + e.cfg.Exit(1) + resp.Body = io.NopCloser(bytes.NewReader(body)) + return resp, nil + } + + // Peek body for GraphQL errors. + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(body)) + + if hasGraphQLErrors(body) && !allowGQLErrorsFromContext(req.Context()) { + for _, m := range extractGraphQLErrorMessages(body) { + fmt.Fprintf(e.cfg.Stderr, "Error: %s\n", m) + } + if e.cfg.ExitOnError { + e.cfg.Exit(1) + } + } + return resp, nil +} + +func decode401Message(body []byte) string { + const inactivity = "Your token has expired due to inactivity" + const defaultMsg = "You are not authorized to perform this request" + const suffix = "; please log out with `vip logout`, then try again." + if len(body) > 0 { + var doc struct { + Code string `json:"code"` + } + if err := json.Unmarshal(body, &doc); err == nil && doc.Code == "token-disabled-inactivity" { + return inactivity + suffix + } + } + return defaultMsg + suffix +} + +func hasGraphQLErrors(body []byte) bool { + var doc struct { + Errors []map[string]any `json:"errors"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return false + } + return len(doc.Errors) > 0 +} + +func extractGraphQLErrorMessages(body []byte) []string { + var doc struct { + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return nil + } + out := make([]string, 0, len(doc.Errors)) + for _, e := range doc.Errors { + out = append(out, e.Message) + } + return out +} diff --git a/internal/gql/error_test.go b/internal/gql/error_test.go new file mode 100644 index 000000000..81ef470ae --- /dev/null +++ b/internal/gql/error_test.go @@ -0,0 +1,203 @@ +package gql + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestErrorMiddleware401InactivityMessage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + w.Write([]byte(`{"code":"token-disabled-inactivity","message":"x"}`)) + })) + defer srv.Close() + var stderr bytes.Buffer + var calledCode int + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewErrorMiddleware(ErrorConfig{ + Stderr: &stderr, Exit: func(code int) { calledCode = code }, + })}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + c.Do(req) + if calledCode != 1 { + t.Errorf("exit code = %d, want 1", calledCode) + } + if !strings.Contains(stderr.String(), "Your token has expired due to inactivity") { + t.Errorf("stderr missing inactivity message: %q", stderr.String()) + } + if !strings.Contains(stderr.String(), "please log out with `vip logout`") { + t.Errorf("stderr missing logout suffix: %q", stderr.String()) + } +} + +func TestErrorMiddleware401DefaultMessage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + w.Write([]byte(`not-json`)) + })) + defer srv.Close() + var stderr bytes.Buffer + var calledCode int + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewErrorMiddleware(ErrorConfig{ + Stderr: &stderr, Exit: func(code int) { calledCode = code }, + })}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + c.Do(req) + if calledCode != 1 { + t.Errorf("exit code = %d, want 1", calledCode) + } + if !strings.Contains(stderr.String(), "You are not authorized to perform this request") { + t.Errorf("stderr missing default message: %q", stderr.String()) + } +} + +func TestErrorMiddleware401Silenced(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + w.Write([]byte(`{"code":"x"}`)) + })) + defer srv.Close() + called := false + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewErrorMiddleware(ErrorConfig{ + Silence: true, Exit: func(int) { called = true }, + })}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + if called { + t.Error("exiter must not be called when Silence is true") + } + if resp.StatusCode != 401 { + t.Errorf("response status = %d, want 401", resp.StatusCode) + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() +} + +func TestErrorMiddlewareGraphQLErrorsExit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{"data":null,"errors":[{"message":"App not found"}]}`)) + })) + defer srv.Close() + var stderr bytes.Buffer + var calledCode int + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewErrorMiddleware(ErrorConfig{ + ExitOnError: true, Stderr: &stderr, Exit: func(code int) { calledCode = code }, + })}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + c.Do(req) + if calledCode != 1 { + t.Errorf("exit code = %d, want 1", calledCode) + } + if !strings.Contains(stderr.String(), "Error:") || !strings.Contains(stderr.String(), "App not found") { + t.Errorf("stderr missing GraphQL error: %q", stderr.String()) + } +} + +func TestErrorMiddlewareGraphQLErrorsNoExit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{"errors":[{"message":"oops"}]}`)) + })) + defer srv.Close() + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewErrorMiddleware(ErrorConfig{ + ExitOnError: false, Exit: func(int) { t.Error("must not exit when ExitOnError is false") }, + })}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), `"errors"`) { + t.Errorf("response body should still contain errors when not exiting: %s", body) + } +} + +// TestErrorMiddlewareWithAllowGQLErrorsSuppressesPrintAndExit pins the +// opt-out contract for WithAllowGQLErrors: a request whose context has the +// flag set must NOT print "Error:" to stderr AND must NOT call Exit on a +// GraphQL errors[] response. The response body remains readable so the +// caller (e.g. sync.Start) can inspect the errors[] inline. +func TestErrorMiddlewareWithAllowGQLErrorsSuppressesPrintAndExit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"errors":[{"message":"Site is already syncing"}]}`)) + })) + defer srv.Close() + var stderr bytes.Buffer + exitCalled := false + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewErrorMiddleware(ErrorConfig{ + Stderr: &stderr, ExitOnError: true, Exit: func(int) { exitCalled = true }, + })}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Sync","query":"mutation{x}"}`)) + req = req.WithContext(WithAllowGQLErrors(req.Context())) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + if exitCalled { + t.Error("Exit must NOT be called when WithAllowGQLErrors is on context") + } + if stderr.Len() != 0 { + t.Errorf("stderr must be empty when opted out; got %q", stderr.String()) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "Site is already syncing") { + t.Errorf("body must still be readable downstream; got %q", body) + } +} + +// TestErrorMiddlewareWithAllowGQLErrorsDoesNotAffect401 verifies the +// documented promise that the opt-out covers only GraphQL errors[], +// NOT the 401 path. A 401 response with WithAllowGQLErrors set must still +// print "Unauthorized:" and call Exit(1). Regression guard so a future +// refactor of error.go can't silently widen the opt-out's scope. +func TestErrorMiddlewareWithAllowGQLErrorsDoesNotAffect401(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(401) + w.Write([]byte(`{"code":"token-disabled-inactivity"}`)) + })) + defer srv.Close() + var stderr bytes.Buffer + var calledCode int + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewErrorMiddleware(ErrorConfig{ + Stderr: &stderr, Exit: func(code int) { calledCode = code }, + })}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + req = req.WithContext(WithAllowGQLErrors(req.Context())) + c.Do(req) + if calledCode != 1 { + t.Errorf("401 must still exit(1) even with WithAllowGQLErrors; got %d", calledCode) + } + if !strings.Contains(stderr.String(), "Unauthorized:") { + t.Errorf("401 must still print 'Unauthorized:'; got %q", stderr.String()) + } +} diff --git a/internal/gql/import_sql_marshal_test.go b/internal/gql/import_sql_marshal_test.go new file mode 100644 index 000000000..f1e577f72 --- /dev/null +++ b/internal/gql/import_sql_marshal_test.go @@ -0,0 +1,69 @@ +package gql + +import ( + "encoding/json" + "strings" + "testing" +) + +// The startImport server resolver calls input.searchReplace.filter(...). If the +// field is omitted from the request (undefined), it crashes with +// "Cannot read properties of undefined (reading 'filter')". Node always sends +// searchReplace: [], so an empty SearchReplace MUST serialize as [] rather than +// be dropped by omitempty. Same applies to urlHeaders on the URL path. +func TestStartImportInputAlwaysSendsSearchReplace(t *testing.T) { + in := &AppEnvironmentImportInput{ + SearchReplace: []*AppEnvironmentImportSearchReplace{}, + } + b, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"searchReplace":[]`) { + t.Fatalf("empty searchReplace dropped (omitempty) — server will crash on .filter(); got: %s", b) + } +} + +// `--search-replace="a"` (no comma) must reach the server as {from:"a"} with +// no `to` key — Node's JSON.stringify drops the undefined arr[1]. A nil *To +// therefore has to be OMITTED, not emitted as null: "to":null and a missing +// `to` are different inputs to the resolver, and "to":"" is worse still +// (delete every occurrence of `from`). Guards the +// @genqlient(for: "AppEnvironmentImportSearchReplace.to", omitempty: true) +// directive in operations/import_sql.graphql. +func TestSearchReplaceOmitsNilTo(t *testing.T) { + from := "a" + b, err := json.Marshal(&AppEnvironmentImportSearchReplace{From: &from}) + if err != nil { + t.Fatal(err) + } + if string(b) != `{"from":"a"}` { + t.Fatalf("got %s, want {\"from\":\"a\"} — a nil To must be omitted, not null", b) + } +} + +// A trailing comma ("a,") is a real second segment in JS, so an explicitly +// empty `to` must still be transmitted. +func TestSearchReplaceKeepsExplicitEmptyTo(t *testing.T) { + from, to := "a", "" + b, err := json.Marshal(&AppEnvironmentImportSearchReplace{From: &from, To: &to}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"to":""`) { + t.Fatalf("got %s, want an explicit \"to\":\"\"", b) + } +} + +func TestStartImportInputAlwaysSendsUrlHeaders(t *testing.T) { + in := &AppEnvironmentImportInput{ + UrlHeaders: []*RequestHeader{}, + } + b, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"urlHeaders":[]`) { + t.Fatalf("empty urlHeaders dropped (omitempty); got: %s", b) + } +} diff --git a/internal/gql/operation.go b/internal/gql/operation.go new file mode 100644 index 000000000..b84c22786 --- /dev/null +++ b/internal/gql/operation.go @@ -0,0 +1,67 @@ +package gql + +import ( + "fmt" + + json "encoding/json/v2" + + "github.com/vektah/gqlparser/v2/ast" + "github.com/vektah/gqlparser/v2/parser" +) + +// Operation describes the relevant shape of a GraphQL request as inspected +// by the rechallenge middleware. +type Operation struct { + OperationName string + IsMutation bool + PrimaryFieldName string // first FIELD in the operation's selection set +} + +// ParseOperationFromBody decodes the JSON request body, parses the contained +// "query" string, and reports whether it's a mutation along with its primary +// field name (the rechallenge "scope"). +func ParseOperationFromBody(body []byte) (*Operation, error) { + var raw struct { + OperationName string `json:"operationName"` + Query string `json:"query"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("decode body: %w", err) + } + if raw.Query == "" { + return nil, fmt.Errorf("body has no query field") + } + doc, err := parser.ParseQuery(&ast.Source{Input: raw.Query}) + if err != nil { + return nil, fmt.Errorf("parse query: %w", err) + } + op := selectOperation(doc, raw.OperationName) + if op == nil { + return nil, fmt.Errorf("no operations in query") + } + out := &Operation{ + OperationName: op.Name, + IsMutation: op.Operation == ast.Mutation, + } + for _, sel := range op.SelectionSet { + if f, ok := sel.(*ast.Field); ok { + out.PrimaryFieldName = f.Name + break + } + } + return out, nil +} + +func selectOperation(doc *ast.QueryDocument, opName string) *ast.OperationDefinition { + if opName != "" { + for _, op := range doc.Operations { + if op.Name == opName { + return op + } + } + } + if len(doc.Operations) > 0 { + return doc.Operations[0] + } + return nil +} diff --git a/internal/gql/operation_test.go b/internal/gql/operation_test.go new file mode 100644 index 000000000..7d75df022 --- /dev/null +++ b/internal/gql/operation_test.go @@ -0,0 +1,60 @@ +package gql + +import "testing" + +func TestParseOperationMutation(t *testing.T) { + body := `{"operationName":"UpdateThing","query":"mutation UpdateThing($x:Int!){updateDefensiveModeStatus(input:{id:$x}){success}}"}` + op, err := ParseOperationFromBody([]byte(body)) + if err != nil { + t.Fatalf("ParseOperationFromBody: %v", err) + } + if !op.IsMutation { + t.Error("IsMutation must be true") + } + if op.PrimaryFieldName != "updateDefensiveModeStatus" { + t.Errorf("PrimaryFieldName = %q, want updateDefensiveModeStatus", op.PrimaryFieldName) + } + if op.OperationName != "UpdateThing" { + t.Errorf("OperationName = %q", op.OperationName) + } +} + +func TestParseOperationQuery(t *testing.T) { + body := `{"operationName":"Me","query":"query Me{me{id displayName}}"}` + op, err := ParseOperationFromBody([]byte(body)) + if err != nil { + t.Fatalf("ParseOperationFromBody: %v", err) + } + if op.IsMutation { + t.Error("IsMutation must be false for a query") + } +} + +func TestParseOperationAnonymousMutation(t *testing.T) { + body := `{"query":"mutation{updateDefensiveModeStatus(input:{}){success}}"}` + op, err := ParseOperationFromBody([]byte(body)) + if err != nil { + t.Fatalf("ParseOperationFromBody: %v", err) + } + if !op.IsMutation { + t.Error("IsMutation must be true") + } + if op.PrimaryFieldName != "updateDefensiveModeStatus" { + t.Errorf("PrimaryFieldName = %q", op.PrimaryFieldName) + } +} + +func TestParseOperationMalformedBody(t *testing.T) { + _, err := ParseOperationFromBody([]byte("not json")) + if err == nil { + t.Error("expected error on malformed body") + } +} + +func TestParseOperationMalformedQuery(t *testing.T) { + body := `{"query":"mutation { broken"}` + _, err := ParseOperationFromBody([]byte(body)) + if err == nil { + t.Error("expected error on malformed GraphQL") + } +} diff --git a/internal/gql/proxy_test.go b/internal/gql/proxy_test.go new file mode 100644 index 000000000..1b279e28d --- /dev/null +++ b/internal/gql/proxy_test.go @@ -0,0 +1,62 @@ +package gql + +import ( + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestClientHonoursVIPProxy pins cutover item 2.14 on the path that carries the +// bearer token. gql.Client defaulted to http.DefaultClient, which ignores +// VIP_PROXY/SOCKS_PROXY entirely (a SOCKS user connected direct and never knew) +// and honours HTTPS_PROXY unconditionally (a user who declined system-proxy use +// had their token routed through a corporate proxy Node bypasses). +// +// The target is a live loopback server and the proxy a closed port. Neither +// net/http nor x/net's httpproxy will ever proxy a loopback host, so reaching +// the server proves the request went direct; Node's proxy-from-env has no such +// exemption, so the request must be attempted through the dead SOCKS port. +func TestClientHonoursVIPProxy(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{}}`)) + })) + defer srv.Close() + + for _, k := range []string{ + "SOCKS_PROXY", "socks_proxy", "HTTPS_PROXY", "https_proxy", + "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", + "NO_PROXY", "no_proxy", "VIP_USE_SYSTEM_PROXY", "vip_proxy", + } { + t.Setenv(k, "") + } + t.Setenv("VIP_PROXY", "socks5://"+closedAddr(t)) + + c := NewClient(Config{APIHost: srv.URL, Token: "bearer-token-under-test"}) + req, err := http.NewRequest(http.MethodPost, srv.URL+"/graphql", + strings.NewReader(`{"operationName":"Me","query":"{me{id}}"}`)) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + + resp, err := c.Do(req) + if err == nil { + _ = resp.Body.Close() + t.Fatal("GraphQL request succeeded; VIP_PROXY was ignored and the bearer token went direct") + } +} + +func closedAddr(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := l.Addr().String() + if err := l.Close(); err != nil { + t.Fatalf("close: %v", err) + } + return addr +} diff --git a/internal/gql/rechallenge.go b/internal/gql/rechallenge.go new file mode 100644 index 000000000..0a0bd176c --- /dev/null +++ b/internal/gql/rechallenge.go @@ -0,0 +1,214 @@ +package gql + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "os" + + json "encoding/json/v2" + + "github.com/Automattic/vip/internal/rechallenge" +) + +const defaultElevatedHeader = "x-elevated-token" + +// RechallengeConfig wires the middleware to its token cache + runner. +type RechallengeConfig struct { + TokenCache *rechallenge.TokenCache + Runner *rechallenge.Runner + // Context, if non-nil, supplies the context used for Parker calls. + // Defaults to context.Background(); production should pass the cobra + // command's ctx so SIGINT cancels the flow. + Context func() context.Context + // Interactive, when non-nil, replaces the default + // rechallenge.IsInteractiveContext(nil) fallback for the Runner's + // Interactive flag. main.go wires this from a closure over the cobra + // command tree so the middleware honors --non-interactive. + Interactive func() bool + // Wait, when non-nil, replaces rechallenge.ShouldWaitForRechallenge as the + // source of the "block on step-up even though nobody is here" opt-in. + // Injected by tests; production reads the environment. + Wait func() bool + // Stderr receives the step-up failure notice. Defaults to os.Stderr. + Stderr io.Writer +} + +// NewRechallengeMiddleware is the real middleware (M3) replacing the M2 no-op. +// +// On each outbound request: +// +// 1. Parse the GraphQL operation from the body. Non-mutations pass through. +// 2. Preflight: if TokenCache has a token for the mutation's primary field, +// attach it to the request as the elevated header. +// 3. Call next. Read response body. +// 4. If response contains errors[] with extensions.code == elevated-permission-required +// and a valid extensions.rechallenge, run the rechallenge flow. +// 5. On flow success: replay request ONCE with the elevated header. +// 6. On flow failure: report WHY to stderr, then return the ORIGINAL response +// (so error middleware sees it and the exit code is unchanged). +// +// Mirrors src/lib/rechallenge/link.ts, except for step 6's report: Node hides +// the step-up failure behind a `debug()` call, so unless DEBUG was already set +// the user is told only that they lack permission — which is neither the +// problem nor actionable. +func NewRechallengeMiddleware(cfg RechallengeConfig) Middleware { + return func(next Doer) Doer { + return &rechallengeDoer{next: next, cfg: cfg} + } +} + +type rechallengeDoer struct { + next Doer + cfg RechallengeConfig +} + +func (r *rechallengeDoer) ctx() context.Context { + if r.cfg.Context != nil { + if c := r.cfg.Context(); c != nil { + return c + } + } + return context.Background() +} + +func (r *rechallengeDoer) Do(req *http.Request) (*http.Response, error) { + // Snapshot body so we can replay it on retry. Mirrors the retry + // middleware's approach; we re-snapshot at this layer for our own retry. + var body []byte + if req.Body != nil { + var err error + body, err = io.ReadAll(req.Body) + if err != nil { + return nil, err + } + req.Body = io.NopCloser(bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + } + + op, opErr := ParseOperationFromBody(body) + if opErr != nil || op == nil || !op.IsMutation || op.PrimaryFieldName == "" { + return r.next.Do(req) + } + scope := op.PrimaryFieldName + + // Preflight: cached elevated token wins. + if r.cfg.TokenCache != nil { + if tok, err := r.cfg.TokenCache.Get(scope); err == nil && tok != nil { + attachElevatedHeader(req, *tok) + } + } + + // First attempt. + resp, err := r.next.Do(req) + if err != nil || resp == nil { + return resp, err + } + + rb, _ := io.ReadAll(resp.Body) + resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(rb)) + + ext := extractElevatedExtension(rb) + if ext == nil { + return resp, nil + } + + if r.cfg.Runner == nil { + return resp, nil + } + + interactive := rechallenge.IsInteractiveContext(nil) + if r.cfg.Interactive != nil { + interactive = r.cfg.Interactive() + } + wait := rechallenge.ShouldWaitForRechallenge() + if r.cfg.Wait != nil { + wait = r.cfg.Wait() + } + tok, runErr := r.cfg.Runner.Run(r.ctx(), rechallenge.RunInput{ + RequestedOperation: scope, + Extension: *ext, + Interactive: interactive, + Wait: wait, + }) + if runErr != nil || tok == nil { + r.reportStepUpFailure(scope, runErr) + // Surface the ORIGINAL error response upstream. + return resp, nil + } + + // Replay with the elevated header. Reuse the original body bytes. + retryReq := req.Clone(req.Context()) + retryReq.Body = io.NopCloser(bytes.NewReader(body)) + retryReq.ContentLength = int64(len(body)) + attachElevatedHeader(retryReq, *tok) + + return r.next.Do(retryReq) +} + +// reportStepUpFailure tells the user why step-up did not produce a token. +// +// Without it the only thing printed is the server's original +// elevated-permission error, which says the user lacks permission — true, but +// it is the symptom, not the cause. "Parker returned HTTP 503", "the approval +// was denied", "this is a non-interactive session" and "the session expired" +// all looked identical, and the reason for each was sitting in an error value +// that was discarded one line later. Same class of bug as 78d0a615. +// +// The text is server-controlled and lands in CI logs and the telemetry exit +// hook, so it goes through RedactSecrets with the bearer token as a known +// secret. rechallenge.Client redacts its own error bodies too; this is the +// second layer, covering error values that do not come from an HTTP body. +func (r *rechallengeDoer) reportStepUpFailure(scope string, runErr error) { + if runErr == nil { + return + } + w := r.cfg.Stderr + if w == nil { + w = os.Stderr + } + var token string + if r.cfg.Runner != nil && r.cfg.Runner.Client != nil { + token = r.cfg.Runner.Client.BearerToken + } + fmt.Fprintf(w, "Step-up verification failed for %s: %s\n", + scope, rechallenge.RedactSecrets(runErr.Error(), token)) +} + +func attachElevatedHeader(req *http.Request, tok rechallenge.ElevatedToken) { + name := tok.HeaderName + if name == "" { + name = defaultElevatedHeader + } + req.Header.Set(name, tok.Token) +} + +// extractElevatedExtension scans the response body for a GraphQL error whose +// extensions.code == elevated-permission-required and whose extensions.rechallenge +// is a complete Extension object. Returns nil if none found. +func extractElevatedExtension(body []byte) *rechallenge.Extension { + var doc struct { + Errors []struct { + Extensions struct { + Code string `json:"code"` + Rechallenge *rechallenge.Extension `json:"rechallenge"` + } `json:"extensions"` + } `json:"errors"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return nil + } + for _, e := range doc.Errors { + if e.Extensions.Code != rechallenge.ElevatedPermissionErrorCode { + continue + } + if e.Extensions.Rechallenge == nil || !e.Extensions.Rechallenge.IsValid() { + continue + } + return e.Extensions.Rechallenge + } + return nil +} diff --git a/internal/gql/rechallenge_test.go b/internal/gql/rechallenge_test.go new file mode 100644 index 000000000..2e87a70fa --- /dev/null +++ b/internal/gql/rechallenge_test.go @@ -0,0 +1,471 @@ +package gql + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Automattic/vip/internal/keychain" + "github.com/Automattic/vip/internal/rechallenge" +) + +func newTestRechallengeCache() *rechallenge.TokenCache { + return &rechallenge.TokenCache{ + Keychain: &keychain.Keychain{Backend: &keychainMemBackend{}, Service: "vip-next-cli:elevated"}, + } +} + +type keychainMemBackend struct{ store map[string]string } + +func (m *keychainMemBackend) Set(s, u, p string) error { + if m.store == nil { + m.store = map[string]string{} + } + m.store[s+"|"+u] = p + return nil +} +func (m *keychainMemBackend) Get(s, u string) (string, error) { + if v, ok := m.store[s+"|"+u]; ok { + return v, nil + } + return "", keychain.ErrNotFound +} +func (m *keychainMemBackend) Delete(s, u string) error { + if _, ok := m.store[s+"|"+u]; !ok { + return keychain.ErrNotFound + } + delete(m.store, s+"|"+u) + return nil +} + +func TestRechallengePassThroughQuery(t *testing.T) { + calls := int32(0) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Write([]byte(`{"data":{"me":null}}`)) + })) + defer srv.Close() + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewRechallengeMiddleware(RechallengeConfig{ + TokenCache: newTestRechallengeCache(), + })}, + }) + body := `{"operationName":"Me","query":"query Me{me{id}}"}` + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(body)) + if _, err := c.Do(req); err != nil { + t.Fatalf("Do: %v", err) + } + if calls != 1 { + t.Errorf("calls = %d, want 1 (query — no rechallenge)", calls) + } +} + +func TestRechallengePreflightAttachesCachedToken(t *testing.T) { + var seenHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenHeader = r.Header.Get("x-elevated-token") + w.Write([]byte(`{"data":{"updateDefensiveModeStatus":{"success":true}}}`)) + })) + defer srv.Close() + cache := newTestRechallengeCache() + cache.Set("updateDefensiveModeStatus", rechallenge.ElevatedToken{ + Token: "cached-token", + ExpiresAt: time.Now().Add(time.Hour), + HeaderName: "x-elevated-token", + }) + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewRechallengeMiddleware(RechallengeConfig{TokenCache: cache})}, + }) + body := `{"operationName":"U","query":"mutation U{updateDefensiveModeStatus(input:{}){success}}"}` + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(body)) + if _, err := c.Do(req); err != nil { + t.Fatalf("Do: %v", err) + } + if seenHeader != "cached-token" { + t.Errorf("x-elevated-token header = %q, want cached-token", seenHeader) + } +} + +func TestRechallengeFullFlowOnElevatedError(t *testing.T) { + mutationHits := int32(0) + var headerAfterRetry string + + parker := http.NewServeMux() + parker.HandleFunc("/parker/sessions", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v","pollIntervalSeconds":0,"expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `"}`)) + }) + parker.HandleFunc("/parker/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"verified","expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `","pollIntervalSeconds":0,"provider":"passkeys"}`)) + }) + parker.HandleFunc("/parker/sessions/c1/exchange", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"elevatedToken":{"token":"elev","expiresAt":"` + time.Now().Add(2*time.Hour).Format(time.RFC3339) + `","purpose":"u"}}`)) + }) + parkerSrv := httptest.NewServer(parker) + defer parkerSrv.Close() + + gql := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&mutationHits, 1) + if n == 1 { + w.Write([]byte(`{"errors":[{"message":"elev required","extensions":{"code":"elevated-permission-required","rechallenge":{"version":"v2","createSessionPath":"` + parkerSrv.URL + `/parker/sessions","statusPathTemplate":"` + parkerSrv.URL + `/parker/sessions/{challengeId}","exchangePathTemplate":"` + parkerSrv.URL + `/parker/sessions/{challengeId}/exchange","elevatedHeaderName":"x-elevated-token"}}}]}`)) + return + } + headerAfterRetry = r.Header.Get("x-elevated-token") + w.Write([]byte(`{"data":{"updateDefensiveModeStatus":{"success":true}}}`)) + })) + defer gql.Close() + + cache := newTestRechallengeCache() + runner := &rechallenge.Runner{ + Client: &rechallenge.Client{APIHost: parkerSrv.URL, HTTP: parkerSrv.Client()}, + TokenCache: cache, + Sleep: func(_ context.Context, _ time.Duration) error { return nil }, + } + c := NewClient(Config{ + APIHost: gql.URL, TestMode: true, + Middleware: []Middleware{NewRechallengeMiddleware(RechallengeConfig{ + TokenCache: cache, + Runner: runner, + // `go test` has no TTY, so the default sensor would report + // non-interactive and (correctly) refuse to open a challenge. + Interactive: func() bool { return true }, + })}, + }) + + body := `{"operationName":"U","query":"mutation U{updateDefensiveModeStatus(input:{}){success}}"}` + req, _ := http.NewRequest("POST", gql.URL+"/graphql", strings.NewReader(body)) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + out, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(out), `"success":true`) { + t.Errorf("expected success after replay; body = %s", out) + } + if mutationHits != 2 { + t.Errorf("mutation hits = %d, want 2 (one bounce + one retry)", mutationHits) + } + if headerAfterRetry != "elev" { + t.Errorf("retry header = %q, want elev", headerAfterRetry) + } +} + +func TestRechallengeSurfacesOriginalErrorOnFlowFailure(t *testing.T) { + parker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + w.Write([]byte("parker boom")) + })) + defer parker.Close() + gqlHits := int32(0) + gql := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&gqlHits, 1) + w.Write([]byte(`{"errors":[{"message":"elev required","extensions":{"code":"elevated-permission-required","rechallenge":{"version":"v2","createSessionPath":"` + parker.URL + `/x","statusPathTemplate":"` + parker.URL + `/x/{challengeId}","exchangePathTemplate":"` + parker.URL + `/x/{challengeId}/y","elevatedHeaderName":"x-elevated-token"}}}]}`)) + })) + defer gql.Close() + cache := newTestRechallengeCache() + runner := &rechallenge.Runner{ + Client: &rechallenge.Client{APIHost: parker.URL, HTTP: parker.Client()}, + TokenCache: cache, + Sleep: func(_ context.Context, _ time.Duration) error { return nil }, + } + c := NewClient(Config{ + APIHost: gql.URL, TestMode: true, + Middleware: []Middleware{NewRechallengeMiddleware(RechallengeConfig{ + TokenCache: cache, Runner: runner, Stderr: io.Discard, + // Interactive so the failure under test is Parker's HTTP 500 and + // not the non-interactive refusal that precedes it. + Interactive: func() bool { return true }, + })}, + }) + body := `{"operationName":"U","query":"mutation U{updateDefensiveModeStatus(input:{}){success}}"}` + req, _ := http.NewRequest("POST", gql.URL+"/graphql", strings.NewReader(body)) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + // Mutation should NOT have retried (gqlHits == 1). + if gqlHits != 1 { + t.Errorf("gql hits = %d, want 1 (no retry when Parker fails)", gqlHits) + } + out, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(out), "elevated-permission-required") { + t.Errorf("original error must be surfaced; body = %s", out) + } +} + +func TestRechallengeUsesConfigInteractivityProvider(t *testing.T) { + parker := http.NewServeMux() + parker.HandleFunc("/parker/sessions", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v","pollIntervalSeconds":0,"expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `"}`)) + }) + parker.HandleFunc("/parker/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"verified","expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `","pollIntervalSeconds":0,"provider":"passkeys"}`)) + }) + parker.HandleFunc("/parker/sessions/c1/exchange", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"elevatedToken":{"token":"elev","expiresAt":"` + time.Now().Add(2*time.Hour).Format(time.RFC3339) + `","purpose":"u"}}`)) + }) + parkerSrv := httptest.NewServer(parker) + defer parkerSrv.Close() + + mutationHits := int32(0) + gql := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&mutationHits, 1) + if n == 1 { + w.Write([]byte(`{"errors":[{"message":"elev required","extensions":{"code":"elevated-permission-required","rechallenge":{"version":"v2","createSessionPath":"` + parkerSrv.URL + `/parker/sessions","statusPathTemplate":"` + parkerSrv.URL + `/parker/sessions/{challengeId}","exchangePathTemplate":"` + parkerSrv.URL + `/parker/sessions/{challengeId}/exchange","elevatedHeaderName":"x-elevated-token"}}}]}`)) + return + } + w.Write([]byte(`{"data":{"updateDefensiveModeStatus":{"success":true}}}`)) + })) + defer gql.Close() + + cache := newTestRechallengeCache() + runner := &rechallenge.Runner{ + Client: &rechallenge.Client{APIHost: parkerSrv.URL, HTTP: parkerSrv.Client()}, + TokenCache: cache, + Sleep: func(_ context.Context, _ time.Duration) error { return nil }, + } + + var interactiveCalls int32 + c := NewClient(Config{ + APIHost: gql.URL, TestMode: true, + Middleware: []Middleware{NewRechallengeMiddleware(RechallengeConfig{ + TokenCache: cache, + Runner: runner, + // Returning true is what makes this test meaningful: the default + // sensor reports non-interactive under `go test` (no TTY), so the + // flow can only reach Parker if the injected provider was consulted. + Interactive: func() bool { + atomic.AddInt32(&interactiveCalls, 1) + return true + }, + })}, + }) + + body := `{"operationName":"U","query":"mutation U{updateDefensiveModeStatus(input:{}){success}}"}` + req, _ := http.NewRequest("POST", gql.URL+"/graphql", strings.NewReader(body)) + if _, err := c.Do(req); err != nil { + t.Fatalf("Do: %v", err) + } + // Must have entered the elevated-flow code path (one bounce + one retry). + if mutationHits < 2 { + t.Errorf("mutation hits = %d, want >= 2 (elevated flow must run for this assertion to be meaningful)", mutationHits) + } + if got := atomic.LoadInt32(&interactiveCalls); got < 1 { + t.Errorf("Interactive provider never called; got %d calls", got) + } +} + +// elevatedBouncer serves a GraphQL endpoint that answers every mutation with an +// elevated-permission-required error pointing at parkerURL. +func elevatedBouncer(t *testing.T, parkerURL string, hits *int32) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if hits != nil { + atomic.AddInt32(hits, 1) + } + w.Write([]byte(`{"errors":[{"message":"You do not have permission to perform this action.","extensions":{"code":"elevated-permission-required","rechallenge":{"version":"v2","createSessionPath":"` + + parkerURL + `/x","statusPathTemplate":"` + parkerURL + `/x/{challengeId}","exchangePathTemplate":"` + + parkerURL + `/x/{challengeId}/y","elevatedHeaderName":"x-elevated-token"}}}]}`)) + })) + t.Cleanup(srv.Close) + return srv +} + +const mutationBody = `{"operationName":"U","query":"mutation U{updateDefensiveModeStatus(input:{}){success}}"}` + +// TestRechallengeSurfacesStepUpFailureReason: when step-up fails, the reason was +// dropped on the floor (`if runErr != nil { return resp, nil }`) and the user saw +// only the generic "you do not have permission" error the server had already +// sent. The diagnosis was in hand and thrown away — same class of bug as +// 78d0a615 in parity/parker_discovery.go. +func TestRechallengeSurfacesStepUpFailureReason(t *testing.T) { + parker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(503) + w.Write([]byte(`{"error":"step-up provider unavailable"}`)) + })) + defer parker.Close() + gqlSrv := elevatedBouncer(t, parker.URL, nil) + + var stderr strings.Builder + cache := newTestRechallengeCache() + c := NewClient(Config{ + APIHost: gqlSrv.URL, TestMode: true, + Middleware: []Middleware{NewRechallengeMiddleware(RechallengeConfig{ + TokenCache: cache, + Stderr: &stderr, + Runner: &rechallenge.Runner{ + Client: &rechallenge.Client{APIHost: parker.URL, HTTP: parker.Client()}, + TokenCache: cache, + Sleep: func(context.Context, time.Duration) error { return nil }, + }, + Interactive: func() bool { return true }, + })}, + }) + req, _ := http.NewRequest("POST", gqlSrv.URL+"/graphql", strings.NewReader(mutationBody)) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + body, _ := io.ReadAll(resp.Body) + + got := stderr.String() + for _, want := range []string{ + "updateDefensiveModeStatus", // which operation + "503", // what the step-up service said + "step-up provider unavailable", // why + } { + if !strings.Contains(got, want) { + t.Errorf("step-up failure notice must mention %q; got %q", want, got) + } + } + // The original GraphQL error still has to reach the error middleware. + if !strings.Contains(string(body), "elevated-permission-required") { + t.Errorf("original error must still be surfaced; body = %s", body) + } +} + +// TestRechallengeFailureReasonCannotLeakToken: the surfaced text is +// server-controlled and reaches CI logs and the telemetry exit hook. Parker +// echoes request context into some payloads, so the worst case is the response +// body containing the caller's own bearer token. +func TestRechallengeFailureReasonCannotLeakToken(t *testing.T) { + const bearer = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJyaW5hdCJ9.c2lnbmF0dXJlLWhlcmU" + parker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + w.Write([]byte(`{"error":"upstream refused","request":{"authorization":"` + + r.Header.Get("Authorization") + `"}}`)) + })) + defer parker.Close() + gqlSrv := elevatedBouncer(t, parker.URL, nil) + + var stderr strings.Builder + cache := newTestRechallengeCache() + c := NewClient(Config{ + APIHost: gqlSrv.URL, TestMode: true, + Middleware: []Middleware{NewRechallengeMiddleware(RechallengeConfig{ + TokenCache: cache, + Stderr: &stderr, + Runner: &rechallenge.Runner{ + Client: &rechallenge.Client{ + APIHost: parker.URL, HTTP: parker.Client(), BearerToken: bearer, + }, + TokenCache: cache, + Sleep: func(context.Context, time.Duration) error { return nil }, + }, + Interactive: func() bool { return true }, + })}, + }) + req, _ := http.NewRequest("POST", gqlSrv.URL+"/graphql", strings.NewReader(mutationBody)) + if _, err := c.Do(req); err != nil { + t.Fatalf("Do: %v", err) + } + if strings.Contains(stderr.String(), bearer) { + t.Fatalf("bearer token leaked into the surfaced step-up failure: %s", stderr.String()) + } + if !strings.Contains(stderr.String(), "upstream refused") { + t.Errorf("redaction must not eat the diagnosis; got %q", stderr.String()) + } +} + +// TestRechallengeNonInteractiveReturnsPromptly is the middleware-level watchdog +// for the CI hang: a mutation that trips step-up under --non-interactive must +// come back with an error immediately instead of polling Parker until the +// verification session expires. It FAILS on timeout rather than hanging, so a +// regression shows up as a red build and not as a stuck job. +func TestRechallengeNonInteractiveReturnsPromptly(t *testing.T) { + var parkerHits int32 + parkerMux := http.NewServeMux() + hour := time.Now().Add(time.Hour).Format(time.RFC3339) + parkerMux.HandleFunc("/x", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&parkerHits, 1) + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v","pollIntervalSeconds":0,"expiresAt":"` + hour + `"}`)) + }) + parkerMux.HandleFunc("/x/c1", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&parkerHits, 1) + w.Write([]byte(`{"challengeId":"c1","status":"pending","expiresAt":"` + hour + `","pollIntervalSeconds":0}`)) + }) + parker := httptest.NewServer(parkerMux) + defer parker.Close() + gqlSrv := elevatedBouncer(t, parker.URL, nil) + + var stderr strings.Builder + cache := newTestRechallengeCache() + c := NewClient(Config{ + APIHost: gqlSrv.URL, TestMode: true, + Middleware: []Middleware{NewRechallengeMiddleware(RechallengeConfig{ + TokenCache: cache, + Stderr: &stderr, + Runner: &rechallenge.Runner{ + Client: &rechallenge.Client{APIHost: parker.URL, HTTP: parker.Client()}, + TokenCache: cache, + Sleep: func(ctx context.Context, _ time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(10 * time.Millisecond): + return nil + } + }, + }, + Interactive: func() bool { return false }, + Wait: func() bool { return false }, + })}, + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, "POST", gqlSrv.URL+"/graphql", strings.NewReader(mutationBody)) + + done := make(chan error, 1) + go func() { + _, err := c.Do(req) + done <- err + }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("Do: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("mutation did not return within 5s under --non-interactive: " + + "step-up is polling a challenge nobody can approve (this is the CI hang)") + } + + if n := atomic.LoadInt32(&parkerHits); n != 0 { + t.Errorf("Parker was called %d times; a non-interactive run must not open a "+ + "verification session no human can complete", n) + } + if !strings.Contains(stderr.String(), "non-interactive") { + t.Errorf("user must be told why step-up was refused; stderr = %q", stderr.String()) + } +} + +func TestRechallengeIgnoresUnrelatedErrors(t *testing.T) { + calls := int32(0) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Write([]byte(`{"errors":[{"message":"validation failed","extensions":{"code":"BAD_REQUEST"}}]}`)) + })) + defer srv.Close() + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewRechallengeMiddleware(RechallengeConfig{ + TokenCache: newTestRechallengeCache(), + })}, + }) + body := `{"operationName":"U","query":"mutation U{updateDefensiveModeStatus(input:{}){success}}"}` + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(body)) + if _, err := c.Do(req); err != nil { + t.Fatalf("Do: %v", err) + } + if calls != 1 { + t.Errorf("calls = %d, want 1 (no rechallenge for unrelated errors)", calls) + } +} diff --git a/internal/gql/retry.go b/internal/gql/retry.go new file mode 100644 index 000000000..f6525bc25 --- /dev/null +++ b/internal/gql/retry.go @@ -0,0 +1,111 @@ +package gql + +import ( + "bytes" + "errors" + "io" + "net/http" + "syscall" + "time" +) + +type RetryConfig struct { + MaxAttempts int + InitialDelay time.Duration + MaxDelay time.Duration + NoDelay bool // tests set this to skip sleeps +} + +func defaultRetryConfig() RetryConfig { + return RetryConfig{ + MaxAttempts: 5, + InitialDelay: 1 * time.Second, + MaxDelay: 5 * time.Second, + } +} + +func NewRetryMiddleware(cfg RetryConfig) Middleware { + if cfg.MaxAttempts == 0 { + cfg = defaultRetryConfig() + } + return func(next Doer) Doer { + return &retryDoer{next: next, cfg: cfg} + } +} + +type retryDoer struct { + next Doer + cfg RetryConfig +} + +func (r *retryDoer) Do(req *http.Request) (*http.Response, error) { + var body []byte + if req.Body != nil { + var err error + body, err = io.ReadAll(req.Body) + if err != nil { + return nil, err + } + req.Body = io.NopCloser(bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + } + retryable := isRetryableOperation(body) + var resp *http.Response + var lastErr error + for attempt := 1; attempt <= r.cfg.MaxAttempts; attempt++ { + if attempt > 1 { + req.Body = io.NopCloser(bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + } + resp, lastErr = r.next.Do(req) + if !shouldRetry(resp, lastErr, retryable, attempt, r.cfg.MaxAttempts) { + return resp, lastErr + } + if resp != nil { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + if !r.cfg.NoDelay { + time.Sleep(backoff(attempt, r.cfg.InitialDelay, r.cfg.MaxDelay)) + } + } + return resp, lastErr +} + +func shouldRetry(resp *http.Response, err error, retryable bool, attempt, maxAttempts int) bool { + if !retryable { + return false + } + if attempt >= maxAttempts { + return false + } + if err != nil { + if errors.Is(err, syscall.ECONNREFUSED) { + return true + } + return false + } + if resp == nil { + return false + } + if resp.StatusCode >= 400 && resp.StatusCode < 500 && resp.StatusCode != 429 { + return false + } + if resp.StatusCode >= 500 || resp.StatusCode == 429 { + return true + } + return false +} + +func isRetryableOperation(body []byte) bool { + op, err := ParseOperationFromBody(body) + return err == nil && !op.IsMutation +} + +func backoff(attempt int, initial, max time.Duration) time.Duration { + d := initial * time.Duration(1< max { + return max + } + return d +} diff --git a/internal/gql/retry_test.go b/internal/gql/retry_test.go new file mode 100644 index 000000000..208454900 --- /dev/null +++ b/internal/gql/retry_test.go @@ -0,0 +1,230 @@ +package gql + +import ( + json "encoding/json/v2" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func graphqlBody(t *testing.T, operationName, query string) string { + t.Helper() + b, err := json.Marshal(map[string]any{ + "operationName": operationName, + "query": query, + }) + if err != nil { + t.Fatalf("marshal GraphQL body: %v", err) + } + return string(b) +} + +func TestRetryQueryOn5xx(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&calls, 1) + if n < 3 { + w.WriteHeader(503) + return + } + w.WriteHeader(200) + w.Write([]byte(`{"data":{"me":null}}`)) + })) + defer srv.Close() + + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewRetryMiddleware(RetryConfig{MaxAttempts: 5, NoDelay: true})}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + if calls != 3 { + t.Errorf("calls = %d, want 3", calls) + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() +} + +func TestNoRetryOnMutation(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(503) + })) + defer srv.Close() + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewRetryMiddleware(RetryConfig{MaxAttempts: 5, NoDelay: true})}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"DoThing","query":"mutation DoThing{doThing{ok}}"}`)) + c.Do(req) + if calls != 1 { + t.Errorf("mutation must not retry; calls = %d, want 1", calls) + } +} + +func TestGeneratedMutationNeverRetries(t *testing.T) { + for _, status := range []int{http.StatusInternalServerError, http.StatusTooManyRequests} { + t.Run(http.StatusText(status), func(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(status) + })) + defer srv.Close() + + c := NewClient(Config{ + APIHost: srv.URL, + TestMode: true, + Middleware: []Middleware{NewRetryMiddleware(RetryConfig{ + MaxAttempts: 5, + NoDelay: true, + })}, + }) + req, err := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader( + graphqlBody(t, "AbortMediaImport", AbortMediaImport_Operation), + )) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + resp, _ := c.Do(req) + if resp != nil { + resp.Body.Close() + } + if calls != 1 { + t.Fatalf("generated mutation status %d calls = %d, want 1", status, calls) + } + }) + } +} + +func TestUnparseableOperationNeverRetries(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + c := NewClient(Config{ + APIHost: srv.URL, + TestMode: true, + Middleware: []Middleware{NewRetryMiddleware(RetryConfig{ + MaxAttempts: 5, + NoDelay: true, + })}, + }) + req, err := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`not-json`)) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + resp, _ := c.Do(req) + if resp != nil { + resp.Body.Close() + } + if calls != 1 { + t.Fatalf("unparseable operation calls = %d, want 1", calls) + } +} + +func TestGeneratedMultilineQueryStillRetries(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + n := atomic.AddInt32(&calls, 1) + if n < 3 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + c := NewClient(Config{ + APIHost: srv.URL, + TestMode: true, + Middleware: []Middleware{NewRetryMiddleware(RetryConfig{ + MaxAttempts: 5, + NoDelay: true, + })}, + }) + req, err := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader( + graphqlBody(t, "Me", Me_Operation), + )) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + resp.Body.Close() + if calls != 3 { + t.Fatalf("generated query calls = %d, want 3", calls) + } +} + +func TestNoRetryOn4xxExcept429(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(401) + })) + defer srv.Close() + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewRetryMiddleware(RetryConfig{MaxAttempts: 5, NoDelay: true})}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + c.Do(req) + if calls != 1 { + t.Errorf("4xx (not 429) must not retry; calls = %d, want 1", calls) + } +} + +func TestRetryOn429(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&calls, 1) + if n == 1 { + w.WriteHeader(429) + return + } + w.WriteHeader(200) + w.Write([]byte(`{"data":{}}`)) + })) + defer srv.Close() + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewRetryMiddleware(RetryConfig{MaxAttempts: 5, NoDelay: true})}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + c.Do(req) + if calls != 2 { + t.Errorf("429 must retry; calls = %d, want 2", calls) + } +} + +func TestRetryStopsAfterMaxAttempts(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.WriteHeader(503) + })) + defer srv.Close() + c := NewClient(Config{ + APIHost: srv.URL, TestMode: true, + Middleware: []Middleware{NewRetryMiddleware(RetryConfig{MaxAttempts: 3, NoDelay: true})}, + }) + req, _ := http.NewRequest("POST", srv.URL+"/graphql", strings.NewReader(`{"operationName":"Me","query":"query Me{me{id}}"}`)) + c.Do(req) + if calls != 3 { + t.Errorf("retry must stop at MaxAttempts; calls = %d, want 3", calls) + } +} diff --git a/internal/gql/transport.go b/internal/gql/transport.go new file mode 100644 index 000000000..abc591aaa --- /dev/null +++ b/internal/gql/transport.go @@ -0,0 +1,56 @@ +package gql + +import ( + json "encoding/json/v2" + "io" + "net/http" + "net/url" + "strings" +) + +type transport struct { + cfg Config +} + +func newTransport(cfg Config) Doer { return &transport{cfg: cfg} } + +// Do rewrites the request URL to include ?x_query= (unless +// TestMode is set, matching the Node behavior in api.ts:127–134). Attaches +// the bearer token if present. +func (t *transport) Do(req *http.Request) (*http.Response, error) { + if !t.cfg.TestMode { + if op, err := operationNameFromBody(req); err == nil && op != "" { + q := req.URL.Query() + q.Set("x_query", op) + req.URL.RawQuery = q.Encode() + } + } + if t.cfg.Token != "" { + req.Header.Set("Authorization", "Bearer "+t.cfg.Token) + } + if req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + return t.cfg.HTTPClient.Do(req) +} + +// operationNameFromBody peeks the JSON body for "operationName" without +// consuming the reader. +func operationNameFromBody(req *http.Request) (string, error) { + if req.Body == nil { + return "", nil + } + buf, err := io.ReadAll(req.Body) + if err != nil { + return "", err + } + req.Body = io.NopCloser(strings.NewReader(string(buf))) + req.ContentLength = int64(len(buf)) + var doc struct { + OperationName string `json:"operationName"` + } + if err := json.Unmarshal(buf, &doc); err != nil { + return "", err + } + return url.QueryEscape(doc.OperationName), nil +} diff --git a/internal/gql/transport_helper.go b/internal/gql/transport_helper.go new file mode 100644 index 000000000..2fb0033e0 --- /dev/null +++ b/internal/gql/transport_helper.go @@ -0,0 +1,24 @@ +package gql + +import "net/http" + +// HTTPClientWithMiddleware returns an *http.Client whose RoundTripper composes +// the supplied middleware chain via the *Client's Do method. Use this to feed +// the same chain (error -> rechallenge -> retry) to a genqlient graphql.Client +// without duplicating the wiring. +// +// The returned *http.Client and the underlying *Client share the same +// transport.HTTPClient (http.DefaultClient by default), so any rechallenge +// retry stays on the same connection pool. +func HTTPClientWithMiddleware(apiHost, token string, mw []Middleware) *http.Client { + client := NewClient(Config{APIHost: apiHost, Token: token, Middleware: mw}) + return &http.Client{Transport: &doerTransport{c: client}} +} + +// doerTransport adapts a *Client (which exposes Do) to net/http.RoundTripper +// so genqlient's graphql.Client can run through our middleware stack. +type doerTransport struct{ c *Client } + +func (d *doerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return d.c.Do(req) +} From 4566ffa0edc5b9922095372c7be2543570b2f4b5 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 20 Aug 2026 18:36:31 -0500 Subject: [PATCH 07/32] feat(go): auth, keychain and step-up rechallenge Ported from vip-cli-golang@72ef2f89 (feature/go-rewrite). --- internal/auth/bypass.go | 84 ++++ internal/auth/bypass_test.go | 97 ++++ internal/auth/login.go | 156 +++++++ internal/auth/login_test.go | 184 ++++++++ internal/auth/logout.go | 31 ++ internal/auth/logout_test.go | 32 ++ internal/auth/store.go | 130 ++++++ internal/auth/store_test.go | 339 ++++++++++++++ internal/auth/token.go | 117 +++++ internal/auth/token_test.go | 90 ++++ internal/httpproxy/callers_test.go | 211 +++++++++ internal/httpproxy/httpproxy.go | 308 +++++++++++++ internal/httpproxy/httpproxy_test.go | 435 ++++++++++++++++++ internal/keychain/fallback.go | 93 ++++ internal/keychain/keychain.go | 197 ++++++++ internal/keychain/keychain_select_test.go | 34 ++ internal/keychain/keychain_test.go | 112 +++++ internal/rechallenge/browser.go | 16 + internal/rechallenge/client.go | 198 ++++++++ internal/rechallenge/client_test.go | 169 +++++++ internal/rechallenge/errors.go | 137 ++++++ internal/rechallenge/errors_test.go | 81 ++++ internal/rechallenge/flow.go | 224 +++++++++ .../rechallenge/flow_noninteractive_test.go | 285 ++++++++++++ internal/rechallenge/flow_test.go | 203 ++++++++ internal/rechallenge/interactive.go | 90 ++++ internal/rechallenge/interactive_test.go | 106 +++++ internal/rechallenge/redact.go | 36 ++ internal/rechallenge/redact_test.go | 142 ++++++ internal/rechallenge/tokencache.go | 148 ++++++ internal/rechallenge/tokencache_test.go | 139 ++++++ internal/rechallenge/types.go | 100 ++++ internal/rechallenge/types_test.go | 56 +++ internal/telemetry/config.go | 34 ++ internal/telemetry/default.go | 67 +++ internal/telemetry/default_endpoint_test.go | 111 +++++ internal/telemetry/pendo.go | 154 +++++++ internal/telemetry/pendo_test.go | 233 ++++++++++ internal/telemetry/scrub.go | 90 ++++ internal/telemetry/scrub_test.go | 131 ++++++ internal/telemetry/tracker.go | 76 +++ internal/telemetry/tracker_test.go | 76 +++ internal/telemetry/tracks.go | 82 ++++ internal/telemetry/tracks_test.go | 129 ++++++ internal/telemetry/uuid.go | 50 ++ internal/telemetry/uuid_test.go | 90 ++++ 46 files changed, 6103 insertions(+) create mode 100644 internal/auth/bypass.go create mode 100644 internal/auth/bypass_test.go create mode 100644 internal/auth/login.go create mode 100644 internal/auth/login_test.go create mode 100644 internal/auth/logout.go create mode 100644 internal/auth/logout_test.go create mode 100644 internal/auth/store.go create mode 100644 internal/auth/store_test.go create mode 100644 internal/auth/token.go create mode 100644 internal/auth/token_test.go create mode 100644 internal/httpproxy/callers_test.go create mode 100644 internal/httpproxy/httpproxy.go create mode 100644 internal/httpproxy/httpproxy_test.go create mode 100644 internal/keychain/fallback.go create mode 100644 internal/keychain/keychain.go create mode 100644 internal/keychain/keychain_select_test.go create mode 100644 internal/keychain/keychain_test.go create mode 100644 internal/rechallenge/browser.go create mode 100644 internal/rechallenge/client.go create mode 100644 internal/rechallenge/client_test.go create mode 100644 internal/rechallenge/errors.go create mode 100644 internal/rechallenge/errors_test.go create mode 100644 internal/rechallenge/flow.go create mode 100644 internal/rechallenge/flow_noninteractive_test.go create mode 100644 internal/rechallenge/flow_test.go create mode 100644 internal/rechallenge/interactive.go create mode 100644 internal/rechallenge/interactive_test.go create mode 100644 internal/rechallenge/redact.go create mode 100644 internal/rechallenge/redact_test.go create mode 100644 internal/rechallenge/tokencache.go create mode 100644 internal/rechallenge/tokencache_test.go create mode 100644 internal/rechallenge/types.go create mode 100644 internal/rechallenge/types_test.go create mode 100644 internal/telemetry/config.go create mode 100644 internal/telemetry/default.go create mode 100644 internal/telemetry/default_endpoint_test.go create mode 100644 internal/telemetry/pendo.go create mode 100644 internal/telemetry/pendo_test.go create mode 100644 internal/telemetry/scrub.go create mode 100644 internal/telemetry/scrub_test.go create mode 100644 internal/telemetry/tracker.go create mode 100644 internal/telemetry/tracker_test.go create mode 100644 internal/telemetry/tracks.go create mode 100644 internal/telemetry/tracks_test.go create mode 100644 internal/telemetry/uuid.go create mode 100644 internal/telemetry/uuid_test.go diff --git a/internal/auth/bypass.go b/internal/auth/bypass.go new file mode 100644 index 000000000..50a4ad9aa --- /dev/null +++ b/internal/auth/bypass.go @@ -0,0 +1,84 @@ +package auth + +import ( + "os" + "strings" +) + +// ShouldBypassAuth reports whether this invocation may run WITHOUT an +// interactive login. It is the port of the argv scan in src/bin/vip.js:190-212. +// +// Scope matters more than the token list: in Node this decides exactly one +// thing — login flow, or not. Either way `runCmd()` gets full API access, +// because src/lib/api/http.ts re-reads the keychain on every request. A true +// return here therefore means "do not prompt", NOT "do not configure the API +// client"; main.go must still hand the command whatever token is stored. +// +// Node's scan really is flat over the whole argv (doesArgvHaveAtLeastOneParam +// is `argv.some(arg => params.includes(arg))`), so `config envvar get help` +// takes this branch on both CLIs. That is only benign because of the rule +// above. +func ShouldBypassAuth(argv []string) bool { + hasHelp := contains(argv, "help", "-h", "--help") + hasVersion := contains(argv, "-v", "--version") + hasLogout := contains(argv, "logout") + hasLogin := contains(argv, "login") + hasDevEnv := contains(argv, "dev-env") + hasSync := contains(argv, "sync") + hasDeploy := contains(argv, "deploy") + hasAppEnv := containsAppEnvArgument(argv) + if hasHelp || hasVersion || hasLogout || hasLogin { + return true + } + // vip.js:196-198 — isDevEnvCommandWithoutEnv. `hasSync` is vip-next-only: + // `dev-env sync sql` pulls a production export, so it gets a login prompt + // instead of Node's bare 401. + if hasDevEnv && !hasAppEnv && !hasSync { + return true + } + if hasDeploy && os.Getenv("WPVIP_DEPLOY_TOKEN") != "" { + return true + } + return false +} + +func contains(argv []string, needles ...string) bool { + set := map[string]struct{}{} + for _, n := range needles { + set[n] = struct{}{} + } + for _, a := range argv { + if _, ok := set[a]; ok { + return true + } + } + return false +} + +// containsAppEnvArgument ports containsAppEnvArgument +// (src/lib/cli/command.js:1128-1134): +// +// parsedAlias.app || parsedAlias.env || argv.includes('--app') || argv.includes('--env') +// +// The two halves have deliberately different reach, and both are reproduced: +// parseEnvAliasFromArgv only looks BEFORE `--` (envAlias.ts:41-47), while the +// flag check is a plain exact-token scan of the whole argv. Consequences, all +// Node's: `--app=example` is missed, and a `--app` after `--` counts. +func containsAppEnvArgument(argv []string) bool { + if containsAlias(argv) { + return true + } + return contains(argv, "--app", "--env") +} + +func containsAlias(argv []string) bool { + for _, a := range argv { + if a == "--" { + return false + } + if strings.HasPrefix(a, "@") && len(a) > 1 { + return true + } + } + return false +} diff --git a/internal/auth/bypass_test.go b/internal/auth/bypass_test.go new file mode 100644 index 000000000..4487adfda --- /dev/null +++ b/internal/auth/bypass_test.go @@ -0,0 +1,97 @@ +package auth + +import ( + "os" + "testing" +) + +func TestShouldBypassAuth(t *testing.T) { + tests := []struct { + name string + argv []string + env map[string]string + want bool + }{ + {"help short", []string{"--help"}, nil, true}, + {"help long", []string{"app", "list", "--help"}, nil, true}, + {"help word", []string{"help"}, nil, true}, + {"-h", []string{"-h"}, nil, true}, + {"version short", []string{"-v"}, nil, true}, + {"version long", []string{"--version"}, nil, true}, + {"logout", []string{"logout"}, nil, true}, + {"dev-env no alias", []string{"dev-env", "start"}, nil, true}, + {"dev-env with alias", []string{"dev-env", "@my-app", "destroy"}, nil, false}, + {"deploy with env token", []string{"app", "deploy"}, map[string]string{"WPVIP_DEPLOY_TOKEN": "x"}, true}, + {"deploy without env token", []string{"app", "deploy"}, nil, false}, + {"plain command", []string{"app", "list"}, nil, false}, + {name: "login bypasses", argv: []string{"login"}, want: true}, + {name: "login with flags bypasses", argv: []string{"login", "--debug"}, want: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, set := tc.env["WPVIP_DEPLOY_TOKEN"]; !set { + os.Unsetenv("WPVIP_DEPLOY_TOKEN") + } + for k, v := range tc.env { + t.Setenv(k, v) + } + got := ShouldBypassAuth(tc.argv) + if got != tc.want { + t.Errorf("ShouldBypassAuth(%v) = %v, want %v", tc.argv, got, tc.want) + } + }) + } +} + +func TestDevEnvSyncRequiresAuth(t *testing.T) { + if ShouldBypassAuth([]string{"dev-env", "sync", "sql", "--slug", "x"}) { + t.Fatal("dev-env sync must NOT bypass auth (it calls the platform)") + } +} + +// TestDevEnvWithAppEnvFlagsRequiresAuth pins Node's containsAppEnvArgument +// (src/lib/cli/command.js:1128-1134), which counts BOTH the @app.env alias and +// the bare --app/--env flags. vip-next only looked for the alias, so +// `dev-env create --app example` skipped auth and the create wizard silently +// lost every app-derived default. +func TestDevEnvWithAppEnvFlagsRequiresAuth(t *testing.T) { + cases := [][]string{ + {"dev-env", "create", "--app", "example"}, + {"dev-env", "create", "--env", "develop"}, + } + for _, argv := range cases { + if ShouldBypassAuth(argv) { + t.Errorf("ShouldBypassAuth(%v) = true; --app/--env is an app/env argument in Node", argv) + } + } +} + +// TestDevEnvAppEnvArgumentMatchesNodeExactTokenScan pins the two ways Node's +// containsAppEnvArgument is *sloppier* than the alias parser it wraps. +// `argv.includes('--app')` is an exact-token, whole-argv scan, so: +// +// - `--app=example` is NOT recognised (Node bug: the wizard bypasses login), +// whereas the alias half of the same function stops at `--`; +// - a `--app` token appearing AFTER `--` IS recognised. +// +// Both are Node's shipping behaviour. They are harmless in practice because a +// bypassed invocation still gets a configured API client on both CLIs (Node +// loads the token per request in api/http.ts) — it only decides whether an +// unauthenticated user gets a login prompt or a 401. +func TestDevEnvAppEnvArgumentMatchesNodeExactTokenScan(t *testing.T) { + if !ShouldBypassAuth([]string{"dev-env", "create", "--app=example"}) { + t.Error("Node's argv.includes('--app') does not match the --app=value form") + } + if ShouldBypassAuth([]string{"dev-env", "exec", "--", "wp", "option", "get", "--app"}) { + t.Error("Node's flag scan is not bounded by --; a later --app still counts") + } +} + +func TestDevEnvNonSyncStillBypasses(t *testing.T) { + if !ShouldBypassAuth([]string{"dev-env", "start", "--slug", "x"}) { + t.Fatal("dev-env start should still bypass auth") + } + if !ShouldBypassAuth([]string{"dev-env", "import", "sql", "f.sql"}) { + t.Fatal("dev-env import should still bypass auth") + } +} diff --git a/internal/auth/login.go b/internal/auth/login.go new file mode 100644 index 000000000..b3f543230 --- /dev/null +++ b/internal/auth/login.go @@ -0,0 +1,156 @@ +package auth + +import ( + "errors" + "fmt" + "io" + "os" + + "github.com/AlecAivazis/survey/v2" + "github.com/pkg/browser" +) + +// TokenURL is the VIP dashboard URL where users retrieve their Personal Access Token. +const TokenURL = "https://dashboard.wpvip.com/me/cli/token" + +// ErrLoginCancelled is returned when the user declines the "Ready to authenticate?" prompt. +var ErrLoginCancelled = errors.New("login: cancelled by user") + +// Sentinels for the already-messaged validation failures (the flow prints the +// user-facing line; the command treats these as a clean exit, Node parity). +var ( + ErrTokenMalformed = errors.New("login: token malformed") + ErrTokenExpired = errors.New("login: token expired") + ErrTokenInvalid = errors.New("login: token invalid") +) + +// IsHandledLoginError reports whether err is a validation failure the flow +// already reported to the user (so the command should exit 0). +func IsHandledLoginError(err error) bool { + return errors.Is(err, ErrTokenMalformed) || + errors.Is(err, ErrTokenExpired) || + errors.Is(err, ErrTokenInvalid) +} + +// Tracker abstracts telemetry so tests can record events without a real client. +type Tracker interface { + Track(name string, props map[string]any) +} + +// LoginFlow holds the injectable dependencies for the login sequence. +// All function fields are optional in tests; nil Tracker/SaveToken/Alias are silently skipped. +type LoginFlow struct { + Stdout io.Writer + Tracker Tracker + Confirm func(prompt string) (bool, error) + OpenURL func(url string) error + ReadToken func() (string, error) + SaveToken func(rawJWT string) error + Alias func(userID int64) +} + +// NewProductionLoginFlow wires real I/O: survey prompts, system browser, keychain store. +func NewProductionLoginFlow(store *Store, tracker Tracker, alias func(int64)) *LoginFlow { + return &LoginFlow{ + Stdout: os.Stdout, + Tracker: tracker, + Confirm: surveyConfirm, + OpenURL: browser.OpenURL, + ReadToken: surveyPasswordReadToken, + SaveToken: store.Save, + Alias: alias, + } +} + +// Run executes the interactive login flow. +// Apart from the vip-next-specific banner, it mirrors src/bin/vip.js lines 92–178. +func (l *LoginFlow) Run() (*Token, error) { + // Print banner: empty line, gradient ANSI art, empty line, subtitle, empty + // line, authenticate line with token URL, empty line. + fmt.Fprintln(l.Stdout) + fmt.Fprintln(l.Stdout, "\x1b[38;2;232;196;142m ██╗ ██╗██╗██████╗ ██████╗██╗ ██╗ ███████╗\x1b[0m") + fmt.Fprintln(l.Stdout, "\x1b[38;2;224;181;118m ██║ ██║██║██╔══██╗ ██╔════╝██║ ██║ ██╔════╝\x1b[0m") + fmt.Fprintln(l.Stdout, "\x1b[38;2;216;164;95m ██║ ██║██║██████╔╝█████╗██║ ██║ ██║ ███████╗\x1b[0m") + fmt.Fprintln(l.Stdout, "\x1b[38;2;205;150;78m ╚██╗ ██╔╝██║██╔═══╝ ╚════╝██║ ██║ ██║ ╚════██║\x1b[0m") + fmt.Fprintln(l.Stdout, "\x1b[38;2;195;137;60m ╚████╔╝ ██║██║ ╚██████╗███████╗██║ ███████║\x1b[0m") + fmt.Fprintln(l.Stdout, "\x1b[38;2;185;124;45m ╚═══╝ ╚═╝╚═╝ ╚═════╝╚══════╝╚═╝ ╚══════╝\x1b[0m") + fmt.Fprintln(l.Stdout) + fmt.Fprintln(l.Stdout, ` VIP-CLI is your tool for interacting with and managing your VIP applications.`) + fmt.Fprintln(l.Stdout) + fmt.Fprintln(l.Stdout, ` Authenticate your installation of VIP-CLI with your Personal Access Token. This URL will be opened in your web browser automatically so that you can retrieve your token: `+TokenURL) + fmt.Fprintln(l.Stdout) + + l.track("login_command_execute", nil) + + ok, err := l.Confirm("Ready to authenticate?") + if err != nil { + return nil, err + } + if !ok { + l.track("login_command_browser_cancelled", nil) + return nil, ErrLoginCancelled + } + + if err := l.OpenURL(TokenURL); err != nil { + l.track("login_command_browser_error", map[string]any{"error": err.Error()}) + } else { + l.track("login_command_browser_opened", nil) + } + + rawInput, err := l.ReadToken() + if err != nil { + return nil, err + } + + tok, err := ParseToken(rawInput) + if err != nil { + fmt.Fprintln(l.Stdout, "The token provided is malformed. Please check the token and try again.") + l.track("login_command_token_submit_error", map[string]any{"error": err.Error()}) + return nil, fmt.Errorf("%w: %v", ErrTokenMalformed, err) + } + + if tok.Expired() { + fmt.Fprintln(l.Stdout, "The token provided is expired. Please log in again to refresh the token.") + l.track("login_command_token_submit_error", map[string]any{"error": "expired"}) + return nil, ErrTokenExpired + } + + if !tok.Valid() { + fmt.Fprintln(l.Stdout, "The provided token is not valid. Please log in again to refresh the token.") + l.track("login_command_token_submit_error", map[string]any{"error": "invalid"}) + return nil, ErrTokenInvalid + } + + if l.SaveToken != nil { + if err := l.SaveToken(tok.Raw); err != nil { + l.track("login_command_token_submit_error", map[string]any{"error": err.Error()}) + return nil, err + } + } + + if l.Alias != nil { + l.Alias(tok.ID) + } + + l.track("login_command_token_submit_success", nil) + return tok, nil +} + +func (l *LoginFlow) track(name string, props map[string]any) { + if l.Tracker == nil { + return + } + l.Tracker.Track(name, props) +} + +func surveyConfirm(prompt string) (bool, error) { + var ans bool + err := survey.AskOne(&survey.Confirm{Message: prompt}, &ans) + return ans, err +} + +func surveyPasswordReadToken() (string, error) { + var token string + err := survey.AskOne(&survey.Password{Message: "Access Token:"}, &token) + return token, err +} diff --git a/internal/auth/login_test.go b/internal/auth/login_test.go new file mode 100644 index 000000000..207f956ca --- /dev/null +++ b/internal/auth/login_test.go @@ -0,0 +1,184 @@ +package auth + +import ( + "bytes" + "errors" + "strings" + "testing" + "time" +) + +type fakeTracker struct { + events []string + props []map[string]any +} + +func (f *fakeTracker) Track(name string, props map[string]any) { + f.events = append(f.events, name) + f.props = append(f.props, props) +} + +func TestLoginPrintsBannerAndTokenURL(t *testing.T) { + var stdout bytes.Buffer + tr := &fakeTracker{} + lf := &LoginFlow{ + Stdout: &stdout, + Tracker: tr, + Confirm: func(string) (bool, error) { return false, nil }, + OpenURL: func(string) error { + t.Fatal("OpenURL must not be called") + return nil + }, + ReadToken: func() (string, error) { + t.Fatal("ReadToken must not be called") + return "", nil + }, + } + _, err := lf.Run() + if !errors.Is(err, ErrLoginCancelled) { + t.Errorf("expected ErrLoginCancelled, got %v", err) + } + out := stdout.String() + wantBanner := "\n" + + "\x1b[38;2;232;196;142m ██╗ ██╗██╗██████╗ ██████╗██╗ ██╗ ███████╗\x1b[0m\n" + + "\x1b[38;2;224;181;118m ██║ ██║██║██╔══██╗ ██╔════╝██║ ██║ ██╔════╝\x1b[0m\n" + + "\x1b[38;2;216;164;95m ██║ ██║██║██████╔╝█████╗██║ ██║ ██║ ███████╗\x1b[0m\n" + + "\x1b[38;2;205;150;78m ╚██╗ ██╔╝██║██╔═══╝ ╚════╝██║ ██║ ██║ ╚════██║\x1b[0m\n" + + "\x1b[38;2;195;137;60m ╚████╔╝ ██║██║ ╚██████╗███████╗██║ ███████║\x1b[0m\n" + + "\x1b[38;2;185;124;45m ╚═══╝ ╚═╝╚═╝ ╚═════╝╚══════╝╚═╝ ╚══════╝\x1b[0m\n\n" + if !strings.HasPrefix(out, wantBanner) { + t.Errorf("new VIP-CLI 5 banner missing:\n%s", out) + } + if !strings.Contains(out, "VIP-CLI is your tool for interacting with and managing your VIP applications.") { + t.Errorf("banner subtitle missing: %q", out) + } + if !strings.Contains(out, "https://dashboard.wpvip.com/me/cli/token") { + t.Errorf("token URL missing: %q", out) + } + if len(tr.events) != 2 || tr.events[0] != "login_command_execute" || tr.events[1] != "login_command_browser_cancelled" { + t.Errorf("events = %v", tr.events) + } +} + +func TestLoginAcceptsValidToken(t *testing.T) { + iat := time.Now().Add(-time.Hour).Unix() + exp := time.Now().Add(time.Hour).Unix() + raw, _ := encodeUnsignedJWT(map[string]any{"id": 7, "iat": iat, "exp": exp}) + var stdout bytes.Buffer + tr := &fakeTracker{} + openCalled := false + saved := "" + aliased := int64(0) + lf := &LoginFlow{ + Stdout: &stdout, + Tracker: tr, + Confirm: func(string) (bool, error) { return true, nil }, + OpenURL: func(u string) error { openCalled = true; return nil }, + ReadToken: func() (string, error) { return raw, nil }, + SaveToken: func(s string) error { saved = s; return nil }, + Alias: func(id int64) { aliased = id }, + } + tok, err := lf.Run() + if err != nil { + t.Fatalf("Run: %v", err) + } + if !openCalled { + t.Error("OpenURL must be called") + } + if tok.ID != 7 { + t.Errorf("tok.ID = %d, want 7", tok.ID) + } + if saved != raw { + t.Errorf("SaveToken not invoked correctly") + } + if aliased != 7 { + t.Errorf("Alias = %d, want 7", aliased) + } + wantEvents := []string{"login_command_execute", "login_command_browser_opened", "login_command_token_submit_success"} + if !equalStringSlices(tr.events, wantEvents) { + t.Errorf("events = %v, want %v", tr.events, wantEvents) + } +} + +func TestLoginPersistenceFailurePreventsAlias(t *testing.T) { + iat := time.Now().Add(-time.Hour).Unix() + exp := time.Now().Add(time.Hour).Unix() + raw, _ := encodeUnsignedJWT(map[string]any{"id": 7, "iat": iat, "exp": exp}) + var stdout bytes.Buffer + tr := &fakeTracker{} + aliasCalled := false + lf := &LoginFlow{ + Stdout: &stdout, + Tracker: tr, + Confirm: func(string) (bool, error) { return true, nil }, + OpenURL: func(string) error { return nil }, + ReadToken: func() (string, error) { return raw, nil }, + SaveToken: func(string) error { return errors.New("save failed") }, + Alias: func(int64) { aliasCalled = true }, + } + if _, err := lf.Run(); err == nil || err.Error() != "save failed" { + t.Fatalf("Run error = %v, want save failed", err) + } + if aliasCalled { + t.Fatal("Alias must not run after persistence failure") + } + if tr.events[len(tr.events)-1] != "login_command_token_submit_error" { + t.Fatalf("last event = %v", tr.events) + } +} + +func TestLoginRejectsMalformedToken(t *testing.T) { + var stdout bytes.Buffer + tr := &fakeTracker{} + lf := &LoginFlow{ + Stdout: &stdout, + Tracker: tr, + Confirm: func(string) (bool, error) { return true, nil }, + OpenURL: func(string) error { return nil }, + ReadToken: func() (string, error) { return "garbage", nil }, + } + _, err := lf.Run() + if !errors.Is(err, ErrTokenMalformed) { + t.Fatalf("expected ErrTokenMalformed, got %v", err) + } + if !strings.Contains(stdout.String(), "The token provided is malformed. Please check the token and try again.") { + t.Errorf("malformed message missing: %q", stdout.String()) + } + if len(tr.events) < 1 || tr.events[len(tr.events)-1] != "login_command_token_submit_error" { + t.Errorf("last event = %v", tr.events) + } +} + +func TestLoginRejectsExpiredToken(t *testing.T) { + iat := time.Now().Add(-2 * time.Hour).Unix() + exp := time.Now().Add(-time.Hour).Unix() + raw, _ := encodeUnsignedJWT(map[string]any{"id": 7, "iat": iat, "exp": exp}) + var stdout bytes.Buffer + tr := &fakeTracker{} + lf := &LoginFlow{ + Stdout: &stdout, + Tracker: tr, + Confirm: func(string) (bool, error) { return true, nil }, + OpenURL: func(string) error { return nil }, + ReadToken: func() (string, error) { return raw, nil }, + } + _, err := lf.Run() + if !errors.Is(err, ErrTokenExpired) { + t.Fatalf("expected ErrTokenExpired, got %v", err) + } + if !strings.Contains(stdout.String(), "The token provided is expired. Please log in again to refresh the token.") { + t.Errorf("expired message missing: %q", stdout.String()) + } +} + +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/auth/logout.go b/internal/auth/logout.go new file mode 100644 index 000000000..19c67c849 --- /dev/null +++ b/internal/auth/logout.go @@ -0,0 +1,31 @@ +package auth + +import ( + "context" + "net/http" + "time" + + "github.com/Automattic/vip/internal/httpproxy" +) + +// PostLogout best-effort invalidates the token server-side (Node logout.ts: +// http('/logout', {method:'post'})). The response status is intentionally +// ignored; only a transport failure returns a non-nil error. The caller always +// purges the local token regardless. +func PostLogout(apiHost, rawToken string) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiHost+"/logout", nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+rawToken) + // NOT http.DefaultClient: this request carries the bearer token, and Node + // routes /logout through api/http.ts's proxy agent. See internal/httpproxy. + resp, err := httpproxy.Client().Do(req) + if err != nil { + return err + } + _ = resp.Body.Close() + return nil +} diff --git a/internal/auth/logout_test.go b/internal/auth/logout_test.go new file mode 100644 index 000000000..5bf5a20f5 --- /dev/null +++ b/internal/auth/logout_test.go @@ -0,0 +1,32 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestPostLogoutSendsBearer(t *testing.T) { + var gotAuth, gotMethod, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth, gotMethod, gotPath = r.Header.Get("Authorization"), r.Method, r.URL.Path + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + if err := PostLogout(srv.URL, "rawtok"); err != nil { + t.Fatalf("PostLogout: %v", err) + } + if gotAuth != "Bearer rawtok" || gotMethod != http.MethodPost || gotPath != "/logout" { + t.Errorf("got %q %q %q", gotMethod, gotPath, gotAuth) + } +} + +func TestPostLogoutIgnoresServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + if err := PostLogout(srv.URL, "tok"); err != nil { + t.Errorf("5xx should be ignored, got %v", err) + } +} diff --git a/internal/auth/store.go b/internal/auth/store.go new file mode 100644 index 000000000..3e0cf9df9 --- /dev/null +++ b/internal/auth/store.go @@ -0,0 +1,130 @@ +package auth + +import ( + "errors" + "log/slog" + "os" + + "github.com/Automattic/vip/internal/keychain" +) + +var ErrNoToken = errors.New("auth: no token stored") + +const legacyFallbackDisabledValue = "1" + +type Store struct { + K *keychain.Keychain + // OnDelete is invoked after a successful Delete (right after the token is + // purged from keychain). Errors are logged at debug level but never returned, + // matching Node's logout flow which proceeds even when tokenCache.clearAll + // throws. Wire this in main.go to rechallenge.TokenCache.ClearAll. + OnDelete func() error +} + +func NewStore(k *keychain.Keychain) *Store { return &Store{K: k} } + +func (s *Store) Save(rawJWT string) error { + if err := s.K.Set(s.K.Account(), rawJWT); err != nil { + return err + } + err := s.K.Backend.Delete(s.K.Service, s.fallbackMarkerAccount()) + if errors.Is(err, keychain.ErrNotFound) { + return nil + } + return err +} + +func (s *Store) Load() (string, error) { + v, err := s.LoadPrimary() + if err == nil { + return v, nil + } + if !errors.Is(err, ErrNoToken) { + return "", err + } + if s.K.LegacyService == "" { + return "", ErrNoToken + } + if _, markerErr := s.K.Backend.Get(s.K.Service, s.fallbackMarkerAccount()); markerErr == nil { + return "", ErrNoToken + } else if !errors.Is(markerErr, keychain.ErrNotFound) { + return "", markerErr + } + v, err = s.K.Backend.Get(s.K.LegacyService, s.K.LegacyService) + if errors.Is(err, keychain.ErrNotFound) { + return "", ErrNoToken + } + return v, err +} + +// tokenOverride returns VIP_TOKEN_OVERRIDE, but only in test mode. +// +// Node gates the same variable on NODE_ENV=test (src/lib/token.ts:105). Go has +// no NODE_ENV, so the gate is GO_ENV=test — the equivalent this repo had already +// settled on before this change: internal/telemetry/tracker.go:83 opts telemetry +// out on GO_ENV=test, and internal/parity/env.go pins GO_ENV alongside NODE_ENV +// for every harness subprocess. NODE_ENV=test is accepted too, so a shell set up +// to drive both CLIs keeps working with one variable. +// +// Honest scope: this is NOT a security boundary. Anyone who can set +// VIP_TOKEN_OVERRIDE in this process's environment can set GO_ENV as well, and +// Node's gate is no stronger. What it does buy is the removal of a much likelier +// non-adversarial failure: a VIP_TOKEN_OVERRIDE left exported in a CI image, a +// shell profile or a .env from an earlier test run silently becoming the +// identity every real command authenticates as — including `logout`, which read +// the override to decide what to revoke but deleted the keychain credential, so +// the two were different tokens. +// +// A gate that an env-var-capable attacker could not defeat would have to be +// compile-time (a build tag, or testing.Testing()). Both were rejected: the +// parity harness drives the SHIPPING binary and needs the hatch, so a +// compile-time gate would mean shipping one binary and testing another. +func tokenOverride() string { + if os.Getenv("GO_ENV") != "test" && os.Getenv("NODE_ENV") != "test" { + return "" + } + return os.Getenv("VIP_TOKEN_OVERRIDE") +} + +// LoadPrimary returns only vip-next's credential (or, in test mode, an explicit +// override). Callers that mutate server-side session state, such as logout, must +// not act on the read-only legacy fallback returned by Load. +func (s *Store) LoadPrimary() (string, error) { + if override := tokenOverride(); override != "" { + return override, nil + } + v, err := s.K.Get(s.K.Account()) + if errors.Is(err, keychain.ErrNotFound) { + return "", ErrNoToken + } + return v, err +} + +func (s *Store) Delete() error { + err := s.K.Delete(s.K.Account()) + missing := errors.Is(err, keychain.ErrNotFound) + if err != nil && !missing { + return err + } + if markerErr := s.K.Backend.Set(s.K.Service, s.fallbackMarkerAccount(), legacyFallbackDisabledValue); markerErr != nil { + return markerErr + } + // Run the hook even when the primary token was already gone — elevated + // tokens may exist independently and need clearing. + if s.OnDelete != nil { + if hookErr := s.OnDelete(); hookErr != nil { + slog.Debug("auth.Store.Delete OnDelete hook failed", "err", hookErr) + } + // Hook wired: logout is idempotent (matches Node's logout.ts which + // proceeds regardless of token state). + return nil + } + if missing { + return ErrNoToken + } + return nil +} + +func (s *Store) fallbackMarkerAccount() string { + return s.K.Service + ":legacy-fallback-disabled" +} diff --git a/internal/auth/store_test.go b/internal/auth/store_test.go new file mode 100644 index 000000000..f34b3663c --- /dev/null +++ b/internal/auth/store_test.go @@ -0,0 +1,339 @@ +package auth + +import ( + "errors" + "os" + "testing" + + "github.com/Automattic/vip/internal/keychain" +) + +type memBackend struct{ store map[string]string } + +func (m *memBackend) Set(s, u, p string) error { + if m.store == nil { + m.store = map[string]string{} + } + m.store[s+"|"+u] = p + return nil +} +func (m *memBackend) Get(s, u string) (string, error) { + if v, ok := m.store[s+"|"+u]; ok { + return v, nil + } + return "", keychain.ErrNotFound +} +func (m *memBackend) Delete(s, u string) error { + if _, ok := m.store[s+"|"+u]; !ok { + return keychain.ErrNotFound + } + delete(m.store, s+"|"+u) + return nil +} + +func newTestStore() *Store { + k := &keychain.Keychain{ + Backend: &memBackend{}, + Service: "vip-next-cli", + LegacyService: "vip-go-cli", + } + return NewStore(k) +} + +func TestStoreSaveAndLoad(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + s := newTestStore() + if err := s.Save("jwt.payload.sig"); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got != "jwt.payload.sig" { + t.Errorf("Load = %q, want %q", got, "jwt.payload.sig") + } +} + +func TestStoreLoadFallsBackToLegacyWhenPrimaryMissing(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + s := newTestStore() + be := s.K.Backend.(*memBackend) + if err := be.Set("vip-go-cli", "vip-go-cli", "legacy-token"); err != nil { + t.Fatalf("seed legacy token: %v", err) + } + + got, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got != "legacy-token" { + t.Fatalf("Load = %q, want legacy-token", got) + } +} + +func TestStoreLoadPrimaryDoesNotReturnLegacyToken(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + s := newTestStore() + be := s.K.Backend.(*memBackend) + if err := be.Set("vip-go-cli", "vip-go-cli", "legacy-token"); err != nil { + t.Fatalf("seed legacy token: %v", err) + } + + if _, err := s.LoadPrimary(); !errors.Is(err, ErrNoToken) { + t.Fatalf("LoadPrimary = %v, want ErrNoToken", err) + } +} + +func TestStoreLoadPrefersPrimaryEvenWhenInvalid(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + s := newTestStore() + be := s.K.Backend.(*memBackend) + if err := be.Set("vip-go-cli", "vip-go-cli", "valid-legacy-token"); err != nil { + t.Fatalf("seed legacy token: %v", err) + } + if err := be.Set("vip-next-cli", "vip-next-cli", "invalid-primary"); err != nil { + t.Fatalf("seed primary token: %v", err) + } + + got, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got != "invalid-primary" { + t.Fatalf("Load = %q, want invalid-primary", got) + } +} + +func TestStoreSaveWritesOnlyPrimaryAndClearsFallbackMarker(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + s := newTestStore() + be := s.K.Backend.(*memBackend) + if err := be.Set("vip-go-cli", "vip-go-cli", "legacy-token"); err != nil { + t.Fatalf("seed legacy token: %v", err) + } + if err := be.Set("vip-next-cli", "vip-next-cli:legacy-fallback-disabled", "1"); err != nil { + t.Fatalf("seed fallback marker: %v", err) + } + + if err := s.Save("new-token"); err != nil { + t.Fatalf("Save: %v", err) + } + if got := be.store["vip-next-cli|vip-next-cli"]; got != "new-token" { + t.Fatalf("primary token = %q, want new-token", got) + } + if got := be.store["vip-go-cli|vip-go-cli"]; got != "legacy-token" { + t.Fatalf("Save changed the legacy token to %q", got) + } + if _, ok := be.store["vip-next-cli|vip-next-cli:legacy-fallback-disabled"]; ok { + t.Fatal("Save did not clear the legacy-fallback marker") + } +} + +func TestStoreDeleteLeavesLegacyAndDisablesFallback(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + s := newTestStore() + be := s.K.Backend.(*memBackend) + if err := be.Set("vip-go-cli", "vip-go-cli", "legacy-token"); err != nil { + t.Fatalf("seed legacy token: %v", err) + } + if err := s.Save("primary-token"); err != nil { + t.Fatalf("Save: %v", err) + } + + if err := s.Delete(); err != nil { + t.Fatalf("Delete: %v", err) + } + if got := be.store["vip-go-cli|vip-go-cli"]; got != "legacy-token" { + t.Fatalf("legacy token = %q, want unchanged legacy-token", got) + } + if _, err := s.Load(); !errors.Is(err, ErrNoToken) { + t.Fatalf("Load after Delete = %v, want ErrNoToken", err) + } +} + +func TestStoreDeleteWithoutPrimaryStillDisablesLegacyFallback(t *testing.T) { + t.Setenv("VIP_TOKEN_OVERRIDE", "") + s := newTestStore() + be := s.K.Backend.(*memBackend) + if err := be.Set("vip-go-cli", "vip-go-cli", "legacy-token"); err != nil { + t.Fatalf("seed legacy token: %v", err) + } + + if err := s.Delete(); !errors.Is(err, ErrNoToken) { + t.Fatalf("Delete without primary = %v, want ErrNoToken", err) + } + if got := be.store["vip-go-cli|vip-go-cli"]; got != "legacy-token" { + t.Fatalf("legacy token = %q, want unchanged legacy-token", got) + } + if _, err := s.Load(); !errors.Is(err, ErrNoToken) { + t.Fatalf("Load after Delete = %v, want ErrNoToken", err) + } +} + +func TestStoreLoadMissingReturnsNotFound(t *testing.T) { + s := newTestStore() + _, err := s.Load() + if !errors.Is(err, ErrNoToken) { + t.Errorf("err = %v, want ErrNoToken", err) + } +} + +func TestStoreDelete(t *testing.T) { + s := newTestStore() + s.Save("x") + if err := s.Delete(); err != nil { + t.Fatalf("Delete: %v", err) + } + _, err := s.Load() + if !errors.Is(err, ErrNoToken) { + t.Errorf("after Delete: err = %v, want ErrNoToken", err) + } +} + +// TestStoreLoadIgnoresOverrideOutsideTestMode pins cutover item 2.15. +// Node honours VIP_TOKEN_OVERRIDE only under NODE_ENV=test +// (src/lib/token.ts:105); vip-next honoured it unconditionally, which turned a +// test escape hatch into a live production auth path. GO_ENV is the Go-side +// equivalent this repo already uses (internal/telemetry/tracker.go:83, +// internal/parity/env.go pins both). +func TestStoreLoadIgnoresOverrideOutsideTestMode(t *testing.T) { + for _, mode := range []map[string]string{ + {"GO_ENV": "", "NODE_ENV": ""}, + {"GO_ENV": "production", "NODE_ENV": "production"}, + {"GO_ENV": "development", "NODE_ENV": ""}, + } { + for k, v := range mode { + t.Setenv(k, v) + } + t.Setenv("VIP_TOKEN_OVERRIDE", "ambient-attacker-token") + + s := newTestStore() + if err := s.Save("keychain-token"); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := s.Load() + if err != nil { + t.Fatalf("Load (%v): %v", mode, err) + } + if got != "keychain-token" { + t.Errorf("Load with %v = %q, want the stored credential", mode, got) + } + primary, err := s.LoadPrimary() + if err != nil { + t.Fatalf("LoadPrimary (%v): %v", mode, err) + } + if primary != "keychain-token" { + t.Errorf("LoadPrimary with %v = %q, want the stored credential", mode, primary) + } + } +} + +// TestLogoutRevokesTheSameTokenItDeletes reproduces the compounding half of +// 2.15. `vip logout` reads the bearer to revoke with LoadPrimary and then purges +// the keychain with Delete. While the override was honoured unconditionally, +// those were two DIFFERENT tokens: `VIP_TOKEN_OVERRIDE=x vip-next logout` +// revoked x server-side and deleted the user's real credential locally, leaving +// a live session nobody could log out of. +func TestLogoutRevokesTheSameTokenItDeletes(t *testing.T) { + t.Setenv("GO_ENV", "") + t.Setenv("NODE_ENV", "") + t.Setenv("VIP_TOKEN_OVERRIDE", "some-other-session") + + s := newTestStore() + if err := s.Save("the-credential-logout-will-delete"); err != nil { + t.Fatalf("Save: %v", err) + } + revoked, err := s.LoadPrimary() + if err != nil { + t.Fatalf("LoadPrimary: %v", err) + } + if err := s.Delete(); err != nil { + t.Fatalf("Delete: %v", err) + } + if revoked != "the-credential-logout-will-delete" { + t.Errorf("logout would revoke %q but delete the stored credential", revoked) + } +} + +// TestStoreLoadIgnoresOverrideWithNoStoredToken is the other half: outside test +// mode the override must not manufacture a session out of nothing. +func TestStoreLoadIgnoresOverrideWithNoStoredToken(t *testing.T) { + t.Setenv("GO_ENV", "") + t.Setenv("NODE_ENV", "") + t.Setenv("VIP_TOKEN_OVERRIDE", "ambient-attacker-token") + + s := newTestStore() + if _, err := s.Load(); !errors.Is(err, ErrNoToken) { + t.Errorf("Load = %v, want ErrNoToken", err) + } +} + +func TestStoreLoadHonorsOverride(t *testing.T) { + t.Setenv("GO_ENV", "test") + s := newTestStore() + // Set a token in the keychain so we confirm the env var wins over it. + if err := s.Save("keychain-token"); err != nil { + t.Fatalf("Save: %v", err) + } + t.Setenv("VIP_TOKEN_OVERRIDE", "override-token") + got, err := s.Load() + if err != nil { + t.Fatalf("Load with override: %v", err) + } + if got != "override-token" { + t.Errorf("Load = %q, want %q", got, "override-token") + } +} + +func TestStoreLoadHonorsOverrideWhenKeychainEmpty(t *testing.T) { + t.Setenv("NODE_ENV", "test") + s := newTestStore() + // No token in keychain; env var should still provide a value. + t.Setenv("VIP_TOKEN_OVERRIDE", "env-only-token") + got, err := s.Load() + if err != nil { + t.Fatalf("Load with override (empty keychain): %v", err) + } + if got != "env-only-token" { + t.Errorf("Load = %q, want %q", got, "env-only-token") + } +} + +// Ensure the override is not active when the env var is unset (regression guard). +func TestStoreLoadNoOverrideWhenEnvUnset(t *testing.T) { + s := newTestStore() + os.Unsetenv("VIP_TOKEN_OVERRIDE") + _, err := s.Load() + if !errors.Is(err, ErrNoToken) { + t.Errorf("expected ErrNoToken without override, got %v", err) + } +} + +func TestStoreDeleteClearsElevatedCache(t *testing.T) { + called := false + s := newTestStore() + s.OnDelete = func() error { + called = true + return nil + } + s.Save("x") + if err := s.Delete(); err != nil { + t.Fatalf("Delete: %v", err) + } + if !called { + t.Error("OnDelete hook must fire after token removal") + } +} + +func TestStoreDeleteHookErrorIsNotFatal(t *testing.T) { + s := newTestStore() + s.OnDelete = func() error { return errors.New("hook boom") } + s.Save("x") + // Hook error must NOT mask successful token removal. Implementations can + // log via debug but Delete returns nil on hook failure (Node's logout + // proceeds even if tokenCache.clearAll throws). + if err := s.Delete(); err != nil { + t.Fatalf("Delete returned hook error; want nil so logout proceeds: %v", err) + } +} diff --git a/internal/auth/token.go b/internal/auth/token.go new file mode 100644 index 000000000..ee478e343 --- /dev/null +++ b/internal/auth/token.go @@ -0,0 +1,117 @@ +// Package auth handles JWT decoding, validation, and the login flow. +// Token signature verification is intentionally NOT performed — the server +// validates on every request. This mirrors src/lib/token.ts. +package auth + +import ( + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + json "encoding/json/v2" + "github.com/golang-jwt/jwt/v5" +) + +// Token holds the decoded, unverified claims from a VIP access token. +// Signature verification is skipped — the API server re-validates on every +// request, matching the behavior of the Node CLI (src/lib/token.ts). +type Token struct { + Raw string + ID int64 + IAT time.Time + Exp time.Time // zero value means "no exp claim" +} + +// ParseToken decodes the JWT claims without verifying the signature. +// Returns an error if raw is empty or the JWT is structurally invalid. +func ParseToken(raw string) (*Token, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.New("token is empty") + } + + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + claims := jwt.MapClaims{} + _, _, err := parser.ParseUnverified(raw, claims) + if err != nil { + return nil, fmt.Errorf("parse jwt: %w", err) + } + + tok := &Token{Raw: raw} + + if id, ok := claims["id"]; ok { + switch v := id.(type) { + case float64: + tok.ID = int64(v) + case int64: + tok.ID = v + case int: + tok.ID = int64(v) + } + } + + if iat, ok := claims["iat"]; ok { + tok.IAT = time.Unix(int64(toFloat(iat)), 0) + } + + if exp, ok := claims["exp"]; ok { + tok.Exp = time.Unix(int64(toFloat(exp)), 0) + } + + return tok, nil +} + +// Valid mirrors token.ts valid(): +// - false if no id +// - false if no iat +// - if no exp: true iff now > iat +// - if exp: true iff now > iat AND now < exp +func (t *Token) Valid() bool { + if t == nil || t.ID == 0 || t.IAT.IsZero() { + return false + } + now := time.Now() + if t.Exp.IsZero() { + return now.After(t.IAT) + } + return now.After(t.IAT) && now.Before(t.Exp) +} + +// Expired mirrors token.ts expired(): +// - false if no exp +// - true iff now > exp (strict greater-than, matching Node's `now > this.exp`) +func (t *Token) Expired() bool { + if t == nil || t.Exp.IsZero() { + return false + } + return time.Now().After(t.Exp) +} + +func toFloat(v any) float64 { + switch x := v.(type) { + case float64: + return x + case int64: + return float64(x) + case int: + return float64(x) + } + return 0 +} + +// encodeUnsignedJWT crafts an alg:none JWT from a claims map via base64url +// encoding. Used only by tests — not part of the production API. +func encodeUnsignedJWT(claims map[string]any) (string, error) { + headerJSON, err := json.Marshal(map[string]any{"alg": "none", "typ": "JWT"}) + if err != nil { + return "", fmt.Errorf("marshal header: %w", err) + } + claimsJSON, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("marshal claims: %w", err) + } + enc := base64.RawURLEncoding + return enc.EncodeToString(headerJSON) + "." + enc.EncodeToString(claimsJSON) + ".", nil +} diff --git a/internal/auth/token_test.go b/internal/auth/token_test.go new file mode 100644 index 000000000..16cfdcc6e --- /dev/null +++ b/internal/auth/token_test.go @@ -0,0 +1,90 @@ +package auth + +import ( + "testing" + "time" +) + +// makeJWT is a thin wrapper around encodeUnsignedJWT for test readability. +func makeJWT(t *testing.T, claims map[string]any) string { + t.Helper() + tok, err := encodeUnsignedJWT(claims) + if err != nil { + t.Fatalf("encodeUnsignedJWT: %v", err) + } + return tok +} + +func TestToken_Valid_ValidToken(t *testing.T) { + now := time.Now() + raw := makeJWT(t, map[string]any{ + "id": float64(42), + "iat": float64(now.Add(-1 * time.Hour).Unix()), + "exp": float64(now.Add(1 * time.Hour).Unix()), + }) + tok, err := ParseToken(raw) + if err != nil { + t.Fatalf("ParseToken error: %v", err) + } + if !tok.Valid() { + t.Error("Valid() should be true for a token with id, past iat, future exp") + } + if tok.Expired() { + t.Error("Expired() should be false for a token with future exp") + } + if tok.ID != 42 { + t.Errorf("ID = %d, want 42", tok.ID) + } +} + +func TestToken_Valid_ExpiredToken(t *testing.T) { + now := time.Now() + raw := makeJWT(t, map[string]any{ + "id": float64(7), + "iat": float64(now.Add(-2 * time.Hour).Unix()), + "exp": float64(now.Add(-1 * time.Hour).Unix()), + }) + tok, err := ParseToken(raw) + if err != nil { + t.Fatalf("ParseToken error: %v", err) + } + if tok.Valid() { + t.Error("Valid() should be false for an expired token") + } + if !tok.Expired() { + t.Error("Expired() should be true for a token whose exp is in the past") + } +} + +func TestToken_Valid_NoID(t *testing.T) { + now := time.Now() + raw := makeJWT(t, map[string]any{ + "iat": float64(now.Add(-1 * time.Hour).Unix()), + "exp": float64(now.Add(1 * time.Hour).Unix()), + }) + tok, err := ParseToken(raw) + if err != nil { + t.Fatalf("ParseToken error: %v", err) + } + if tok.Valid() { + t.Error("Valid() should be false when no id claim") + } +} + +func TestParseToken_Malformed(t *testing.T) { + _, err := ParseToken("this.is.not.a.jwt.at.all") + if err == nil { + t.Error("ParseToken should return an error for a malformed JWT") + } +} + +func TestParseToken_Empty(t *testing.T) { + _, err := ParseToken("") + if err == nil { + t.Error("ParseToken should return an error for an empty string") + } + _, err = ParseToken(" ") + if err == nil { + t.Error("ParseToken should return an error for a whitespace-only string") + } +} diff --git a/internal/httpproxy/callers_test.go b/internal/httpproxy/callers_test.go new file mode 100644 index 000000000..ebabc3ce6 --- /dev/null +++ b/internal/httpproxy/callers_test.go @@ -0,0 +1,211 @@ +package httpproxy + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +// tokenBearingSources are the production files whose requests carry a VIP +// credential — a keychain bearer token, a WPVIP_DEPLOY_TOKEN, or a presigned URL +// whose query string is itself the credential. Every one of them must build its +// client from this package. +// +// http.DefaultClient and http.DefaultTransport are the failure mode: they apply +// http.ProxyFromEnvironment, which is the exact inversion of Node's policy — +// HTTPS_PROXY is honoured without the VIP_USE_SYSTEM_PROXY opt-in, and +// VIP_PROXY/SOCKS_PROXY are ignored. The behavioural proofs live in +// httpproxy_test.go, internal/gql/proxy_test.go and internal/upload/proxy_test.go; +// this list is the cheap guard that stops a seventh call site being added +// without one. +// +// TestNoProductionCodeBuildsAnUnproxiedHTTPClient is the complement: this list +// is opt-IN (these named files must reach for the package), that scan is +// opt-OUT (no file anywhere may build a client the package did not vend). +var tokenBearingSources = []string{ + "../gql/client.go", + "../upload/presign.go", + "../auth/logout.go", + "../rechallenge/client.go", + "../wpstream/engineio.go", + "../sqlexport/download.go", + "../telemetry/tracks.go", + "../telemetry/pendo.go", +} + +func TestTokenBearingClientsDoNotUseTheDefaultTransport(t *testing.T) { + for _, rel := range tokenBearingSources { + src, err := os.ReadFile(filepath.Clean(rel)) + if err != nil { + t.Errorf("read %s: %v (did the file move? update tokenBearingSources)", rel, err) + continue + } + // A bare &http.Client{} is just as wrong — it inherits + // http.DefaultTransport's proxy policy — and is not greppable, so + // require the file to reach for this package explicitly. + if !strings.Contains(string(src), "httpproxy.") { + t.Errorf("%s never calls into internal/httpproxy; an http.Client built without "+ + "an explicit Transport inherits http.DefaultTransport's proxy policy", rel) + } + for _, banned := range []string{"http.DefaultClient", "http.DefaultTransport"} { + for _, line := range strings.Split(string(src), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "//") { + continue // prose may name it + } + if strings.Contains(trimmed, banned) { + t.Errorf("%s uses %s; use httpproxy.Client()/Transport() so VIP_PROXY is "+ + "honoured and HTTPS_PROXY is not honoured without VIP_USE_SYSTEM_PROXY\n\t%s", + rel, banned, trimmed) + } + } + } + } +} + +// unproxiedConstructors are the ways production code can end up on +// http.DefaultTransport's proxy policy — the inverse of Node's. +// +// http.Get/Head/Post/PostForm are http.DefaultClient in disguise. A +// `&http.Client{...}` literal with no Transport field is the same thing with a +// timeout bolted on, which is what made the four call sites this scan was +// written for look deliberate. +var unproxiedConstructors = []string{ + "http.DefaultClient", + "http.DefaultTransport", + "http.Get(", + "http.Head(", + "http.Post(", + "http.PostForm(", +} + +// scanExemptDirs are the trees the scan does not walk. +// +// - internal/httpproxy is the package that vends the sanctioned constructors; +// it necessarily names http.DefaultTransport in order to clone it. +// - internal/parity is the differential-test harness, gated behind +// `//go:build parity`. It deliberately talks to a local Parker with its own +// client, and its whole point is ambient independence — the Makefile scrubs +// every proxy variable before running it. +var scanExemptDirs = []string{ + filepath.Join("internal", "httpproxy"), + filepath.Join("internal", "parity"), +} + +// TestNoProductionCodeBuildsAnUnproxiedHTTPClient walks every non-test Go file +// under internal/ and cmd/ and fails on any HTTP client that did not come from +// this package. +// +// tokenBearingSources could only ever catch a regression in a file someone had +// already thought about. This scan catches the file nobody thought about: at +// the commit it was written it found four live call sites on +// http.DefaultTransport, one of them (the WordPress version manifest) a request +// Node explicitly routes through createProxyAgent. +func TestNoProductionCodeBuildsAnUnproxiedHTTPClient(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repo root: %v", err) + } + for _, tree := range []string{"internal", "cmd"} { + walkGoSources(t, filepath.Join(root, tree), root, func(rel string, src []byte) { + for _, line := range codeLines(string(src)) { + for _, banned := range unproxiedConstructors { + if strings.Contains(line.text, banned) { + t.Errorf("%s:%d builds an HTTP client on http.DefaultTransport's proxy "+ + "policy via %s. Use httpproxy.Client()/ClientWithTimeout() so VIP_PROXY is "+ + "honoured and HTTPS_PROXY is not honoured without VIP_USE_SYSTEM_PROXY; use "+ + "httpproxy.DirectClientWithTimeout() when the target is the user's own "+ + "machine and must never be proxied.\n\t%s", + rel, line.num, banned, line.text) + } + } + if lit, ok := clientLiteral(line.text); ok && !strings.Contains(lit, "Transport:") { + t.Errorf("%s:%d constructs http.Client with no Transport, so it inherits "+ + "http.DefaultTransport's proxy policy. Use httpproxy.ClientWithTimeout() "+ + "(or DirectClientWithTimeout() for the user's own machine).\n\t%s", + rel, line.num, line.text) + } + } + }) + } +} + +type sourceLine struct { + num int + text string +} + +// codeLines drops whole-line comments so prose may name the banned symbols — +// several files explain at length why they are NOT using http.DefaultClient. +func codeLines(src string) []sourceLine { + var out []sourceLine + for i, raw := range strings.Split(src, "\n") { + trimmed := strings.TrimSpace(raw) + if trimmed == "" || strings.HasPrefix(trimmed, "//") { + continue + } + out = append(out, sourceLine{num: i + 1, text: trimmed}) + } + return out +} + +// clientLiteral returns the body of an `http.Client{...}` composite literal +// starting on this line, up to the matching brace. A multi-line literal is +// truncated at end of line, which errs in the safe direction: one whose +// Transport field sits on a later line reports a false positive rather than +// letting a real unproxied client through. +func clientLiteral(line string) (string, bool) { + idx := strings.Index(line, "http.Client{") + if idx < 0 { + return "", false + } + rest := line[idx+len("http.Client"):] + depth := 0 + for i, r := range rest { + switch r { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return rest[:i+1], true + } + } + } + return rest, true +} + +func walkGoSources(t *testing.T, dir, root string, fn func(rel string, src []byte)) { + t.Helper() + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + return relErr + } + if d.IsDir() { + for _, skip := range scanExemptDirs { + if rel == skip { + return filepath.SkipDir + } + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + src, readErr := os.ReadFile(filepath.Clean(path)) + if readErr != nil { + return readErr + } + fn(rel, src) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", dir, err) + } +} diff --git a/internal/httpproxy/httpproxy.go b/internal/httpproxy/httpproxy.go new file mode 100644 index 000000000..eaee855c6 --- /dev/null +++ b/internal/httpproxy/httpproxy.go @@ -0,0 +1,308 @@ +// Package httpproxy is the Go port of src/lib/http/proxy-agent.ts, plus the +// parts of the `proxy-from-env` npm package that file depends on. +// +// It exists because the two runtimes disagree about the DEFAULT. Node reaches +// the API through node-fetch with an explicit agent (src/lib/api/http.ts:42), +// and node-fetch reads no proxy environment of its own — so an ambient +// HTTPS_PROXY is ignored unless the user sets VIP_USE_SYSTEM_PROXY. Go's +// http.DefaultTransport reads HTTP_PROXY/HTTPS_PROXY unconditionally, so +// vip-next was routing bearer tokens through proxies the Node CLI deliberately +// bypassed, while ignoring the VIP_PROXY/SOCKS_PROXY variables Node does honour. +// +// Every vip-next HTTP client that talks to the VIP API must use Client() or +// Transport() rather than http.DefaultClient. +package httpproxy + +import ( + "fmt" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +// ProxyURL is the port of createProxyAgent (proxy-agent.ts:20-46). It is shaped +// as an http.Transport.Proxy func: nil means "connect directly". +// +// Precedence, verbatim from the source's own comment: +// +// 1. VIP_PROXY set: a SOCKS proxy, unconditionally — before the opt-in gate +// and before NO_PROXY. This is the pre-system-proxy behaviour and stays +// backward compatible. +// 2. Nothing applicable set: no proxy. +// 3. VIP_USE_SYSTEM_PROXY and SOCKS_PROXY: SOCKS. +// 4. VIP_USE_SYSTEM_PROXY and HTTPS_PROXY: HTTP CONNECT. Note that Node checks +// HTTPS_PROXY for EVERY target, not only https:// ones, and never consults +// HTTP_PROXY at all. +// 5. NO_PROXY alongside the opt-in: see coveredInNoProxy. +// +// Errors are returned rather than swallowed. A proxy the user configured but +// that we cannot honour must fail the request; silently connecting direct is +// how the SOCKS half of this bug went unnoticed. +func ProxyURL(req *http.Request) (*url.URL, error) { + if req == nil || req.URL == nil { + return nil, nil + } + target := req.URL + + // 1. VIP Socks Proxy takes precedence and is fully backward compatible. + if vipProxy := firstEnv("VIP_PROXY", "vip_proxy"); vipProxy != "" { + return socksProxyURL(vipProxy) + } + + // 2-5. System proxy usage, gated on the explicit opt-in. + if os.Getenv("VIP_USE_SYSTEM_PROXY") == "" { + return nil, nil + } + noProxy := firstEnv("NO_PROXY", "no_proxy") + if coveredInNoProxy(target, noProxy) { + return nil, nil + } + if socksProxy := firstEnv("SOCKS_PROXY", "socks_proxy"); socksProxy != "" { + return socksProxyURL(socksProxy) + } + if httpsProxy := firstEnv("HTTPS_PROXY", "https_proxy"); httpsProxy != "" { + return httpsProxyURL(httpsProxy) + } + return nil, nil +} + +// Transport returns an http.Transport with vip-next's proxy policy and +// otherwise the stdlib defaults (connection pooling, timeouts, HTTP/2). +func Transport() *http.Transport { + base, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return &http.Transport{Proxy: ProxyURL} + } + t := base.Clone() + t.Proxy = ProxyURL + return t +} + +// Client returns an http.Client with vip-next's proxy policy and no timeout, +// matching http.DefaultClient in every other respect. +func Client() *http.Client { return &http.Client{Transport: Transport()} } + +// ClientWithTimeout is Client with a per-request deadline. +func ClientWithTimeout(d time.Duration) *http.Client { + c := Client() + c.Timeout = d + return c +} + +// DirectClientWithTimeout returns a client that NEVER consults a proxy, for the +// requests whose target is the user's own machine. +// +// The dev-environment health probe is the case that motivated it: it fetches +// https://.vipdev.site/, a name /etc/hosts maps to 127.0.0.1. ProxyURL +// applies VIP_PROXY unconditionally and exempts no loopback — deliberately, to +// match proxy-from-env — so routing that probe through the policy would break +// every developer with the VIP SOCKS proxy exported: the proxy would resolve +// and dial .vipdev.site on its OWN side, where the containers do not +// exist. Node does not proxy it either; Lando's health check is internal, and +// the single dev-environment request Node hands to createProxyAgent is the +// WordPress version manifest (dev-environment-core.ts:1044). +// +// This exists so "goes direct" is a decision a reader can grep for. A bare +// &http.Client{} would go direct today for a different, accidental reason — +// http.DefaultTransport ignores VIP_PROXY entirely — and would silently start +// honouring an ambient HTTPS_PROXY, which is the bug this package removed. +func DirectClientWithTimeout(d time.Duration) *http.Client { + t := directTransport() + return &http.Client{Transport: t, Timeout: d} +} + +func directTransport() *http.Transport { + base, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return &http.Transport{} + } + t := base.Clone() + t.Proxy = nil + return t +} + +// socksProxyURL is the SocksProxyAgent constructor's Go equivalent. +// +// Divergence, deliberate and loud: socks-proxy-agent also speaks socks4 and +// socks4a, which net/http cannot. Rather than fall back to a direct connection +// — the exact silent failure this package exists to remove — an unsupported +// scheme is an error. "socks" is socks5 in socks-proxy-agent, and net/http +// treats socks5 and socks5h identically. +func socksProxyURL(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid SOCKS proxy %s: %w", redact(raw), err) + } + switch u.Scheme { + case "socks", "": + u.Scheme = "socks5" + case "socks5", "socks5h": + case "socks4", "socks4a": + return nil, fmt.Errorf("SOCKS proxy %s: socks4/socks4a is not supported; use socks5", redact(raw)) + default: + return nil, fmt.Errorf("SOCKS proxy %s: unsupported scheme %q", redact(raw), u.Scheme) + } + if u.Host == "" { + return nil, fmt.Errorf("SOCKS proxy %s has no host", redact(raw)) + } + return u, nil +} + +// redact strips the userinfo from a proxy URL before it can reach an error +// message. Proxy URLs routinely carry credentials, and these errors do not stay +// on the machine: cmd/vip-next/main.go registers an exit hook that ships the +// error text to the telemetry endpoint. The host is deliberately preserved — +// the user needs to know WHICH proxy setting is wrong. +func redact(raw string) string { + if u, err := url.Parse(raw); err == nil && u.User != nil { + return u.Redacted() + } + // Unparseable, or no userinfo. Fall back to a textual cut at "@" so a + // malformed value with an embedded password still cannot escape. + if at := strings.LastIndex(raw, "@"); at >= 0 { + if scheme := strings.Index(raw, "://"); scheme >= 0 && scheme+3 <= at { + return raw[:scheme+3] + "xxxxx@" + raw[at+1:] + } + return "xxxxx@" + raw[at+1:] + } + return raw +} + +// httpsProxyURL is the HttpsProxyAgent constructor's Go equivalent. A value +// with no scheme (`proxy.example:3128`) is read as http://, which is what +// golang.org/x/net/http/httpproxy does; https-proxy-agent's URL parse would +// simply produce a hostless agent, so there is no useful behaviour to copy. +func httpsProxyURL(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + if u2, err2 := url.Parse("http://" + raw); err2 == nil && u2.Host != "" { + return u2, nil + } + } + if err != nil { + return nil, fmt.Errorf("invalid HTTPS proxy %s: %w", redact(raw), err) + } + if u.Host == "" { + return nil, fmt.Errorf("HTTPS proxy %s has no host", redact(raw)) + } + return u, nil +} + +// coveredInNoProxy ports proxy-agent.ts:60-68. +// +// The early return is load-bearing: getProxyForUrl cannot distinguish "NO_PROXY +// matched" from "no proxy variable applies to this URL", so proxy-agent.ts only +// asks it once NO_PROXY is actually set. The conflation survives anyway in one +// configuration, and it is Node's: with NO_PROXY set and SOCKS_PROXY as the only +// proxy variable, getProxyForUrl returns "" — it has never heard of SOCKS_PROXY +// — so the SOCKS proxy is suppressed even for a host NO_PROXY does not name. +func coveredInNoProxy(target *url.URL, noProxy string) bool { + if noProxy == "" { + return false + } + return getProxyForURL(target) == "" +} + +// defaultPorts mirrors proxy-from-env's DEFAULT_PORTS. +var defaultPorts = map[string]int{ + "ftp": 21, "gopher": 70, "http": 80, "https": 443, "ws": 80, "wss": 443, +} + +// getProxyForURL ports proxy-from-env's getProxyForUrl. +func getProxyForURL(target *url.URL) string { + proto := target.Scheme + hostname := strings.ToLower(target.Hostname()) + if hostname == "" || proto == "" { + return "" + } + port := defaultPorts[proto] + if p := target.Port(); p != "" { + if parsed, err := strconv.Atoi(p); err == nil { + port = parsed + } + } + if !shouldProxy(hostname, port) { + return "" + } + proxy := firstOf( + envAnyCase("npm_config_"+proto+"_proxy"), + envAnyCase(proto+"_proxy"), + envAnyCase("npm_config_proxy"), + envAnyCase("all_proxy"), + ) + if proxy != "" && !strings.Contains(proxy, "://") { + proxy = proto + "://" + proxy + } + return proxy +} + +// shouldProxy ports proxy-from-env's shouldProxy: the NO_PROXY ruleset. +// A "*" alone proxies nothing; a leading "." or "*" is a suffix match; +// "host:port" only applies to that port; anything else is an exact host match. +func shouldProxy(hostname string, port int) bool { + noProxy := strings.ToLower(firstOf(envAnyCase("npm_config_no_proxy"), envAnyCase("no_proxy"))) + if noProxy == "" { + return true + } + if noProxy == "*" { + return false + } + for _, entry := range strings.FieldsFunc(noProxy, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == '\f' || r == '\v' + }) { + if entry == "" { + continue + } + entryHost := entry + if idx := strings.LastIndex(entry, ":"); idx > 0 { + if entryPort, err := strconv.Atoi(entry[idx+1:]); err == nil { + if entryPort != port { + continue // rule is for a different port + } + entryHost = entry[:idx] + } + } + if !strings.HasPrefix(entryHost, ".") && !strings.HasPrefix(entryHost, "*") { + if hostname == entryHost { + return false + } + continue + } + suffix := strings.TrimPrefix(entryHost, "*") + if strings.HasSuffix(hostname, suffix) { + return false + } + } + return true +} + +// firstEnv returns the first non-empty value among the named variables, in the +// order proxy-agent.ts reads them (UPPER_CASE first, then lower_case). +func firstEnv(names ...string) string { + for _, n := range names { + if v := os.Getenv(n); v != "" { + return v + } + } + return "" +} + +// envAnyCase ports proxy-from-env's getEnv, which checks lower case first. +func envAnyCase(key string) string { + if v := os.Getenv(strings.ToLower(key)); v != "" { + return v + } + return os.Getenv(strings.ToUpper(key)) +} + +func firstOf(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} diff --git a/internal/httpproxy/httpproxy_test.go b/internal/httpproxy/httpproxy_test.go new file mode 100644 index 000000000..4ea1f01a2 --- /dev/null +++ b/internal/httpproxy/httpproxy_test.go @@ -0,0 +1,435 @@ +package httpproxy + +import ( + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + xproxy "golang.org/x/net/http/httpproxy" +) + +// proxyEnv is every variable the selection logic reads. Tests clear all of +// them and set back only what they mean to exercise, because the ambient shell +// (or `make test-parity-unit-hostile`) may export any of them. +var proxyEnv = []string{ + "VIP_PROXY", "vip_proxy", + "SOCKS_PROXY", "socks_proxy", + "HTTPS_PROXY", "https_proxy", + "HTTP_PROXY", "http_proxy", + "ALL_PROXY", "all_proxy", + "NO_PROXY", "no_proxy", + "VIP_USE_SYSTEM_PROXY", + "npm_config_proxy", "npm_config_https_proxy", "npm_config_http_proxy", "npm_config_no_proxy", +} + +func clearProxyEnv(t *testing.T) { + t.Helper() + for _, k := range proxyEnv { + // An empty value reads the same as unset everywhere the selection + // logic looks (Node tests truthiness; we test != ""), and t.Setenv + // restores the original for us. + t.Setenv(k, "") + } +} + +func mustParse(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse %q: %v", raw, err) + } + return u +} + +func proxyFor(t *testing.T, target string) *url.URL { + t.Helper() + req := &http.Request{URL: mustParse(t, target)} + got, err := ProxyURL(req) + if err != nil { + t.Fatalf("ProxyURL(%s): %v", target, err) + } + return got +} + +// TestSystemProxyIsOptInOnly is the priority half of cutover item 2.14. +// +// Node reaches the API through node-fetch with an explicit agent from +// createProxyAgent (src/lib/api/http.ts:42). node-fetch reads no proxy +// environment of its own, so HTTPS_PROXY alone is DELIBERATELY ignored: the +// module comment (proxy-agent.ts:9-11) says VIP_USE_SYSTEM_PROXY is what opts a +// user in. Go's http.DefaultTransport reads HTTPS_PROXY unconditionally, so a +// user who declined system-proxy use had their bearer token routed through a +// corporate proxy Node bypassed. +// +// The assertion is a direct contrast with the resolver net/http uses by +// default, so it cannot pass by accident: that resolver must select the proxy +// here and ours must not. (x/net's copy is the same code net/http vendors, +// used directly because http.ProxyFromEnvironment caches the environment in a +// sync.Once and would not see t.Setenv.) +func TestSystemProxyIsOptInOnly(t *testing.T) { + clearProxyEnv(t) + t.Setenv("HTTPS_PROXY", "http://corp-proxy.example:3128") + t.Setenv("HTTP_PROXY", "http://corp-proxy.example:3128") + + req := &http.Request{URL: mustParse(t, "https://api.wpvip.com/graphql")} + + stdlib, err := xproxy.FromEnvironment().ProxyFunc()(req.URL) + if err != nil { + t.Fatalf("stdlib ProxyFunc: %v", err) + } + if stdlib == nil { + t.Fatal("precondition failed: the stdlib resolver should have picked HTTPS_PROXY") + } + + got, err := ProxyURL(req) + if err != nil { + t.Fatalf("ProxyURL: %v", err) + } + if got != nil { + t.Errorf("HTTPS_PROXY was honoured without VIP_USE_SYSTEM_PROXY: %s", got) + } +} + +// TestSystemProxyHonouredWhenOptedIn is the other side: once the user opts in, +// HTTPS_PROXY applies regardless of the target's scheme (createProxyAgent reads +// HTTPS_PROXY for every URL, not just https ones). +func TestSystemProxyHonouredWhenOptedIn(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_USE_SYSTEM_PROXY", "1") + t.Setenv("HTTPS_PROXY", "http://corp-proxy.example:3128") + + for _, target := range []string{"https://api.wpvip.com/graphql", "http://api.wpvip.com/upload"} { + got := proxyFor(t, target) + if got == nil || got.Host != "corp-proxy.example:3128" { + t.Errorf("ProxyURL(%s) = %v, want corp-proxy.example:3128", target, got) + } + } +} + +// TestVIPProxyWinsAndNeedsNoOptIn pins precedence rule 1 in proxy-agent.ts: +// VIP_PROXY is checked before the VIP_USE_SYSTEM_PROXY gate and before +// NO_PROXY, "fully backward compatible" with the pre-system-proxy module. +func TestVIPProxyWinsAndNeedsNoOptIn(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_PROXY", "socks5://127.0.0.1:1080") + t.Setenv("SOCKS_PROXY", "socks5://ignored.example:1080") + t.Setenv("HTTPS_PROXY", "http://ignored.example:3128") + t.Setenv("NO_PROXY", "*") + + got := proxyFor(t, "https://api.wpvip.com/graphql") + if got == nil { + t.Fatal("VIP_PROXY must apply with no opt-in and regardless of NO_PROXY") + } + if got.Scheme != "socks5" || got.Host != "127.0.0.1:1080" { + t.Errorf("ProxyURL = %s, want socks5://127.0.0.1:1080", got) + } +} + +// TestSocksProxyPreferredOverHTTPSWhenOptedIn pins rules 3 and 4: with the +// opt-in set, SOCKS_PROXY beats HTTPS_PROXY. +func TestSocksProxyPreferredOverHTTPSWhenOptedIn(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_USE_SYSTEM_PROXY", "1") + t.Setenv("SOCKS_PROXY", "socks5://socks.example:1080") + t.Setenv("HTTPS_PROXY", "http://corp-proxy.example:3128") + + got := proxyFor(t, "https://api.wpvip.com/graphql") + if got == nil || got.Scheme != "socks5" || got.Host != "socks.example:1080" { + t.Errorf("ProxyURL = %v, want socks5://socks.example:1080", got) + } +} + +// TestNoProxyAppliesOnlyToTheSystemProxyBranch pins rule 5, including the +// quirk it inherits from proxy-from-env: coveredInNoProxy asks getProxyForUrl, +// which returns an empty string both when NO_PROXY matches AND when no +// http(s)_proxy applies +// to the URL at all. So a NO_PROXY that does not match still suppresses a +// SOCKS_PROXY-only configuration. +func TestNoProxyAppliesOnlyToTheSystemProxyBranch(t *testing.T) { + t.Run("matching NO_PROXY suppresses the system proxy", func(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_USE_SYSTEM_PROXY", "1") + t.Setenv("HTTPS_PROXY", "http://corp-proxy.example:3128") + t.Setenv("NO_PROXY", "api.wpvip.com") + + if got := proxyFor(t, "https://api.wpvip.com/graphql"); got != nil { + t.Errorf("ProxyURL = %s, want nil (host is in NO_PROXY)", got) + } + }) + + t.Run("non-matching NO_PROXY leaves the system proxy in place", func(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_USE_SYSTEM_PROXY", "1") + t.Setenv("HTTPS_PROXY", "http://corp-proxy.example:3128") + t.Setenv("NO_PROXY", "internal.example") + + if got := proxyFor(t, "https://api.wpvip.com/graphql"); got == nil { + t.Error("ProxyURL = nil, want the system proxy (host is not in NO_PROXY)") + } + }) + + t.Run("NO_PROXY does not touch VIP_PROXY", func(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_PROXY", "socks5://127.0.0.1:1080") + t.Setenv("NO_PROXY", "api.wpvip.com") + + if got := proxyFor(t, "https://api.wpvip.com/graphql"); got == nil { + t.Error("ProxyURL = nil; VIP_PROXY is checked before the NO_PROXY branch") + } + }) + + t.Run("wildcard NO_PROXY suppresses subdomains", func(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_USE_SYSTEM_PROXY", "1") + t.Setenv("HTTPS_PROXY", "http://corp-proxy.example:3128") + t.Setenv("NO_PROXY", ".wpvip.com") + + if got := proxyFor(t, "https://api.wpvip.com/graphql"); got != nil { + t.Errorf("ProxyURL = %s, want nil (.wpvip.com covers api.wpvip.com)", got) + } + }) +} + +// TestNoProxyIsIgnoredWhenUnset guards the early return in coveredInNoProxy: +// proxy-from-env cannot express "no NO_PROXY set", so proxy-agent.ts short- +// circuits before calling it. Dropping that check would make every request +// unproxied, since getProxyForUrl knows nothing about SOCKS_PROXY. +func TestNoProxyIsIgnoredWhenUnset(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_USE_SYSTEM_PROXY", "1") + t.Setenv("SOCKS_PROXY", "socks5://socks.example:1080") + + if got := proxyFor(t, "https://api.wpvip.com/graphql"); got == nil { + t.Error("ProxyURL = nil, want the SOCKS proxy (NO_PROXY is unset)") + } +} + +// TestNoProxySet returns to the quirk above with a concrete assertion, so a +// future "cleanup" that makes SOCKS_PROXY survive an unrelated NO_PROXY is +// caught as the divergence it would be. +func TestNoProxySetSuppressesSocksOnlyConfig(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_USE_SYSTEM_PROXY", "1") + t.Setenv("SOCKS_PROXY", "socks5://socks.example:1080") + t.Setenv("NO_PROXY", "unrelated.example") + + if got := proxyFor(t, "https://api.wpvip.com/graphql"); got != nil { + t.Errorf("ProxyURL = %s; getProxyForUrl knows no SOCKS var, so it returns '' "+ + "and coveredInNoProxy reports true — Node's behaviour", got) + } +} + +func TestNoProxyEnvIsAllUnsetByDefault(t *testing.T) { + clearProxyEnv(t) + if got := proxyFor(t, "https://api.wpvip.com/graphql"); got != nil { + t.Errorf("ProxyURL = %s, want nil with no proxy variables set", got) + } +} + +func TestUnsupportedSocksSchemeFailsLoudly(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_PROXY", "socks4://legacy.example:1080") + + req := &http.Request{URL: mustParse(t, "https://api.wpvip.com/graphql")} + if _, err := ProxyURL(req); err == nil { + t.Error("socks4 is unsupported by net/http; it must fail, not connect direct") + } +} + +// TestProxyErrorsDoNotLeakCredentials guards a path this slice creates. Proxy +// URLs routinely carry userinfo (socks5://user:pass@host), and these errors do +// not stay local: cmd/vip-next/main.go registers an exit hook that ships the +// error text to the telemetry endpoint. Whatever we put in the message leaves +// the machine. +func TestProxyErrorsDoNotLeakCredentials(t *testing.T) { + const secret = "hunter2-proxy-password" + cases := map[string]string{ + "VIP_PROXY": "socks4://alice:" + secret + "@legacy.example:1080", + "SOCKS_PROXY": "gopher://alice:" + secret + "@weird.example:1080", + } + for envVar, value := range cases { + t.Run(envVar, func(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_USE_SYSTEM_PROXY", "1") + t.Setenv(envVar, value) + + req := &http.Request{URL: mustParse(t, "https://api.wpvip.com/graphql")} + _, err := ProxyURL(req) + if err == nil { + t.Fatal("expected an error for an unsupported proxy scheme") + } + if strings.Contains(err.Error(), secret) { + t.Errorf("proxy password appears in the error text: %v", err) + } + if !strings.Contains(err.Error(), "legacy.example") && + !strings.Contains(err.Error(), "weird.example") { + t.Errorf("error must still name the host so the user can fix it: %v", err) + } + }) + } +} + +// TestClientHonoursVIPProxy is the end-to-end half, and reproduces the +// empirical finding in the parity review verbatim: with +// VIP_PROXY=socks5://127.0.0.1:, Node exits 1 (Socket closed) while +// vip-next exited 0, having ignored the variable completely. +// +// The target is a live loopback server on purpose. Neither +// http.ProxyFromEnvironment nor golang.org/x/net/http/httpproxy will ever proxy +// a loopback host, so a client that still reaches the server is proof the +// request went direct. Node has no such exemption (proxy-from-env's shouldProxy +// only consults NO_PROXY), so the request must be attempted through the dead +// SOCKS port and fail. +func TestClientHonoursVIPProxy(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + clearProxyEnv(t) + t.Setenv("VIP_PROXY", "socks5://"+closedLoopbackAddr(t)) + + resp, err := Client().Get(target.URL) + if err == nil { + _ = resp.Body.Close() + t.Fatal("request succeeded; VIP_PROXY was ignored and the connection went direct") + } +} + +// TestDirectClientIsNeverProxied covers the other kind of request vip-next +// makes: one whose target is the user's OWN machine. +// +// The dev-environment health probe fetches https://.vipdev.site/, a name +// /etc/hosts maps to 127.0.0.1. Routing it through the policy would break every +// developer with VIP_PROXY exported — an A8c laptop's normal state — because a +// SOCKS proxy resolves and dials that name on the PROXY's side, where the +// developer's containers do not exist. Node never proxies it either: the one +// dev-environment request it hands to createProxyAgent is the WordPress version +// manifest (dev-environment-core.ts:1044), and Lando's health check is internal. +// +// So this needs to be a deliberate, greppable "never proxy", not a bare +// &http.Client{} that merely happens to go direct today. +// +// Both clients are exercised against the same server under the same environment +// so the assertion cannot pass vacuously: our own policy has no loopback +// exemption, so ClientWithTimeout MUST fail here. If it ever starts succeeding, +// the direct half proves nothing and this test says so. +func TestDirectClientIsNeverProxied(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + clearProxyEnv(t) + t.Setenv("VIP_PROXY", "socks5://"+closedLoopbackAddr(t)) + + resp, err := ClientWithTimeout(5 * time.Second).Get(target.URL) + if err == nil { + _ = resp.Body.Close() + t.Fatal("precondition failed: the proxied client reached a loopback target, so this " + + "test can no longer tell a direct client apart from a proxied one") + } + + resp, err = DirectClientWithTimeout(5 * time.Second).Get(target.URL) + if err != nil { + t.Fatalf("DirectClientWithTimeout was routed through VIP_PROXY: %v", err) + } + _ = resp.Body.Close() +} + +// TestDirectClientIgnoresSystemProxyToo pins the same guarantee against the +// variables the stdlib honours by default, at the transport level — a loopback +// target could never demonstrate this, since neither resolver proxies loopback. +func TestDirectClientIgnoresSystemProxyToo(t *testing.T) { + clearProxyEnv(t) + t.Setenv("VIP_USE_SYSTEM_PROXY", "1") + t.Setenv("HTTPS_PROXY", "http://corp-proxy.example:3128") + t.Setenv("HTTP_PROXY", "http://corp-proxy.example:3128") + + req := &http.Request{URL: mustParse(t, "https://example.invalid/health")} + + stdlib, err := xproxy.FromEnvironment().ProxyFunc()(req.URL) + if err != nil { + t.Fatalf("stdlib ProxyFunc: %v", err) + } + if stdlib == nil { + t.Fatal("precondition failed: the stdlib resolver should have picked HTTPS_PROXY") + } + if got, err := ProxyURL(req); err != nil || got == nil { + t.Fatalf("precondition failed: our own policy should proxy this (got %v, %v)", got, err) + } + + tr, ok := DirectClientWithTimeout(time.Second).Transport.(*http.Transport) + if !ok { + t.Fatalf("DirectClientWithTimeout transport is %T, want *http.Transport", DirectClientWithTimeout(time.Second).Transport) + } + if tr.Proxy != nil { + got, err := tr.Proxy(req) + t.Fatalf("direct transport has a Proxy func returning (%v, %v); it must be nil", got, err) + } +} + +// TestClientDoesNotProxyWithoutOptIn is the security assertion at the client +// level. The proxy is a live loopback recorder and the target is a name that +// cannot resolve, so a request reaching the recorder can only have got there +// through the proxy. +// +// Both halves are exercised in one test on purpose. A client built with the +// policy net/http applies by default hands the request — Authorization header +// and all — straight to a proxy the user never opted into; ours must not. Only +// asserting our own side would pass vacuously, because a DNS failure and a +// declined proxy look identical from the caller. +func TestClientDoesNotProxyWithoutOptIn(t *testing.T) { + seen := 0 + recorder := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + seen++ + w.WriteHeader(http.StatusOK) + })) + defer recorder.Close() + + clearProxyEnv(t) + t.Setenv("HTTPS_PROXY", recorder.URL) + t.Setenv("HTTP_PROXY", recorder.URL) + t.Setenv("ALL_PROXY", recorder.URL) + + const target = "http://vip-cli-parity.invalid/graphql" + + stdlibPolicy := &http.Client{Transport: &http.Transport{ + Proxy: func(r *http.Request) (*url.URL, error) { + return xproxy.FromEnvironment().ProxyFunc()(r.URL) + }, + }} + if resp, err := stdlibPolicy.Get(target); err == nil { + _ = resp.Body.Close() + } + if seen != 1 { + t.Fatalf("precondition failed: the stdlib policy should have proxied; recorder saw %d", seen) + } + + seen = 0 + if resp, err := Client().Get(target); err == nil { + _ = resp.Body.Close() + } + if seen != 0 { + t.Fatalf("proxy received %d request(s); the token would have gone to a proxy the user never opted into", seen) + } +} + +// closedLoopbackAddr returns a loopback host:port that is guaranteed to refuse +// connections: it binds, reads the assigned port, then closes the listener. +func closedLoopbackAddr(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := l.Addr().String() + if err := l.Close(); err != nil { + t.Fatalf("close listener: %v", err) + } + return addr +} diff --git a/internal/keychain/fallback.go b/internal/keychain/fallback.go new file mode 100644 index 000000000..7ac199888 --- /dev/null +++ b/internal/keychain/fallback.go @@ -0,0 +1,93 @@ +package keychain + +import ( + json "encoding/json/v2" + "os" + "path/filepath" + "sync" +) + +// FileBackend stores credentials in $Dir/credentials.json with mode 0600. +// Used on hosts without an OS credential store (headless Linux without +// libsecret, some CI). Emits a one-time warning on first use via the +// caller. +type FileBackend struct { + Dir string + mu sync.Mutex +} + +type fileStore struct { + Entries map[string]string `json:"entries"` +} + +func (b *FileBackend) path() string { return filepath.Join(b.Dir, "credentials.json") } + +func key(service, user string) string { return service + "|" + user } + +func (b *FileBackend) load() (*fileStore, error) { + data, err := os.ReadFile(b.path()) + if os.IsNotExist(err) { + return &fileStore{Entries: map[string]string{}}, nil + } + if err != nil { + return nil, err + } + s := &fileStore{} + if err := json.Unmarshal(data, s); err != nil { + return nil, err + } + if s.Entries == nil { + s.Entries = map[string]string{} + } + return s, nil +} + +func (b *FileBackend) save(s *fileStore) error { + if err := os.MkdirAll(b.Dir, 0o700); err != nil { + return err + } + data, err := json.Marshal(s, json.Deterministic(true)) + if err != nil { + return err + } + return os.WriteFile(b.path(), data, 0o600) +} + +func (b *FileBackend) Set(service, user, secret string) error { + b.mu.Lock() + defer b.mu.Unlock() + s, err := b.load() + if err != nil { + return err + } + s.Entries[key(service, user)] = secret + return b.save(s) +} + +func (b *FileBackend) Get(service, user string) (string, error) { + b.mu.Lock() + defer b.mu.Unlock() + s, err := b.load() + if err != nil { + return "", err + } + v, ok := s.Entries[key(service, user)] + if !ok { + return "", ErrNotFound + } + return v, nil +} + +func (b *FileBackend) Delete(service, user string) error { + b.mu.Lock() + defer b.mu.Unlock() + s, err := b.load() + if err != nil { + return err + } + if _, ok := s.Entries[key(service, user)]; !ok { + return ErrNotFound + } + delete(s.Entries, key(service, user)) + return b.save(s) +} diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go new file mode 100644 index 000000000..8c52f9df5 --- /dev/null +++ b/internal/keychain/keychain.go @@ -0,0 +1,197 @@ +// Package keychain wraps the OS credential store. +// +// On macOS, Windows, and Linux+libsecret it uses zalando/go-keyring. +// The file fallback (Task 9) covers headless Linux where Secret Service +// is not available. +// +// vip-next owns a separate credential namespace so its keyring representation +// cannot overwrite credentials used by the Node CLI. The legacy Node service +// name is retained for read-only, best-effort token fallback: +// +// - vip-next production → "vip-next-cli" +// - Node production → "vip-go-cli" +// - Non-production → ":" +// +// where is the full API host URL with every non-alphanumeric +// character replaced by "-", matching: +// +// API_HOST.replace(/[^a-z0-9]/gi, '-') (src/lib/token.ts getServiceName) +// +// Callers pass k.Account() — which equals k.Service — as the user argument to +// primary Set/Get/Delete operations. Legacy entries are never written or +// deleted by this package's authentication store. +package keychain + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "sync" + + keyring "github.com/zalando/go-keyring" +) + +// ProductionAPIHost is the canonical production endpoint. Both the private +// and legacy namespaces omit a host suffix for this endpoint. +const ProductionAPIHost = "https://api.wpvip.com" + +const ( + // service is the Go CLI's private credential namespace. + service = "vip-next-cli" + // legacyService is the Node CLI namespace used only for best-effort reads. + legacyService = "vip-go-cli" +) + +// ErrNotFound is returned by Get/Delete when the secret does not exist. +var ErrNotFound = errors.New("keychain: secret not found") + +// nonAlphanumeric matches characters that Node replaces with "-". +var nonAlphanumeric = regexp.MustCompile(`[^a-zA-Z0-9]`) + +// Backend abstracts the credential store so tests can inject an in-memory +// double and the file fallback can satisfy the same interface. +type Backend interface { + Set(service, user, secret string) error + Get(service, user string) (string, error) + Delete(service, user string) error +} + +// Keychain is a scoped handle to a particular credential namespace. +type Keychain struct { + Backend Backend + Service string + LegacyService string +} + +// New returns a Keychain with private and legacy service names derived from the +// same API host. +// +// It uses the OS keyring where one is available, and falls back to a 0600 file +// store on a headless Linux box where the Secret Service (D-Bus) is not +// reachable so vip-next still works over SSH, in WSL, and in CI. +func New(host string) *Keychain { + backend := chooseBackend(runtime.GOOS, secretServiceReachable, fallbackDir()) + if fb, ok := backend.(*FileBackend); ok { + warnFileFallbackOnce(fb.path()) + } + return &Keychain{ + Backend: backend, + Service: ServiceNameForHost(host), + LegacyService: LegacyServiceNameForHost(host), + } +} + +// Account returns the private service name used as the default account for +// password operations. +func (k *Keychain) Account() string { return k.Service } + +// Set stores secret under the given user account. +func (k *Keychain) Set(user, secret string) error { + return k.Backend.Set(k.Service, user, secret) +} + +// Get retrieves the secret stored under user. Returns ErrNotFound when absent. +func (k *Keychain) Get(user string) (string, error) { + return k.Backend.Get(k.Service, user) +} + +// Delete removes the secret stored under user. Returns ErrNotFound when absent. +func (k *Keychain) Delete(user string) error { + return k.Backend.Delete(k.Service, user) +} + +func serviceNameForHost(base, host string) string { + // Normalise trailing slash so comparison is robust. + normalized := strings.TrimRight(host, "/") + if normalized == ProductionAPIHost { + return base + } + sanitized := nonAlphanumeric.ReplaceAllString(normalized, "-") + return base + ":" + sanitized +} + +// ServiceNameForHost derives vip-next's private service name from an API host. +func ServiceNameForHost(host string) string { + return serviceNameForHost(service, host) +} + +// LegacyServiceNameForHost derives the Node CLI service name used only for +// best-effort token reads. +func LegacyServiceNameForHost(host string) string { + return serviceNameForHost(legacyService, host) +} + +// defaultBackend delegates to zalando/go-keyring (OS credential store). +type defaultBackend struct{} + +func (defaultBackend) Set(svc, user, secret string) error { + return keyring.Set(svc, user, secret) +} + +func (defaultBackend) Get(svc, user string) (string, error) { + v, err := keyring.Get(svc, user) + if errors.Is(err, keyring.ErrNotFound) { + return "", ErrNotFound + } + return v, err +} + +func (defaultBackend) Delete(svc, user string) error { + err := keyring.Delete(svc, user) + if errors.Is(err, keyring.ErrNotFound) { + return ErrNotFound + } + return err +} + +// secretServiceProbeUser is a sentinel account used only to probe whether the +// Linux Secret Service is reachable; it is never stored. +const secretServiceProbeUser = "__vip_secret_service_probe__" + +// chooseBackend picks the OS keyring, or the file fallback on a headless Linux +// box where the Secret Service is unavailable. macOS and Windows always have a +// credential store, so their probe is skipped. Kept pure (probe + dir injected) +// so the selection is unit-testable. +func chooseBackend(goos string, keyringReachable func() bool, fileDir string) Backend { + if goos != "linux" || keyringReachable() { + return defaultBackend{} + } + return &FileBackend{Dir: fileDir} +} + +// secretServiceReachable probes the Linux Secret Service with a cheap Get: +// keyring.ErrNotFound means it is reachable (the probe account is simply +// absent); any other error (e.g. no D-Bus session bus on a headless host) +// means it is unavailable. +func secretServiceReachable() bool { + _, err := keyring.Get(service, secretServiceProbeUser) + return err == nil || errors.Is(err, keyring.ErrNotFound) +} + +// fallbackDir is where the file backend writes credentials.json when the OS +// keyring is unavailable — the user config dir (…/vip), alongside where the +// Node CLI's configstore fallback lives. +func fallbackDir() string { + if d, err := os.UserConfigDir(); err == nil && d != "" { + return filepath.Join(d, "vip") + } + if h, err := os.UserHomeDir(); err == nil && h != "" { + return filepath.Join(h, ".vip") + } + return "vip" +} + +// fileFallbackWarnOnce guards the single stderr notice below. +var fileFallbackWarnOnce sync.Once + +// warnFileFallbackOnce prints one stderr notice that credentials are stored in a +// file rather than the OS keyring (the FileBackend's expected caller warning). +func warnFileFallbackOnce(path string) { + fileFallbackWarnOnce.Do(func() { + fmt.Fprintf(os.Stderr, "warning: OS keyring unavailable; storing credentials in %s (0600)\n", path) + }) +} diff --git a/internal/keychain/keychain_select_test.go b/internal/keychain/keychain_select_test.go new file mode 100644 index 000000000..915b35cbe --- /dev/null +++ b/internal/keychain/keychain_select_test.go @@ -0,0 +1,34 @@ +package keychain + +import "testing" + +func TestChooseBackend(t *testing.T) { + up := func() bool { return true } + down := func() bool { return false } + + // Headless Linux (Secret Service unreachable) -> file fallback. + if _, ok := chooseBackend("linux", down, "/tmp/vip").(*FileBackend); !ok { + t.Fatalf("linux without a reachable keyring must use the file fallback") + } + // Linux with a working Secret Service -> OS keyring. + if _, ok := chooseBackend("linux", up, "/tmp/vip").(defaultBackend); !ok { + t.Fatalf("linux with a reachable keyring must use the OS keyring") + } + // macOS / Windows always have a credential store; the probe must be skipped. + for _, goos := range []string{"darwin", "windows"} { + probed := false + probe := func() bool { probed = true; return false } + if _, ok := chooseBackend(goos, probe, "/tmp/vip").(defaultBackend); !ok { + t.Fatalf("%s must use the OS keyring", goos) + } + if probed { + t.Fatalf("%s must not probe the Secret Service", goos) + } + } +} + +func TestFallbackDir(t *testing.T) { + if d := fallbackDir(); d == "" { + t.Fatal("fallbackDir must return a non-empty path") + } +} diff --git a/internal/keychain/keychain_test.go b/internal/keychain/keychain_test.go new file mode 100644 index 000000000..ad41454d3 --- /dev/null +++ b/internal/keychain/keychain_test.go @@ -0,0 +1,112 @@ +package keychain + +import ( + "errors" + "os" + "testing" +) + +type memBackend struct { + store map[string]string +} + +func (m *memBackend) Set(service, user, secret string) error { + if m.store == nil { + m.store = map[string]string{} + } + m.store[service+"|"+user] = secret + return nil +} +func (m *memBackend) Get(service, user string) (string, error) { + v, ok := m.store[service+"|"+user] + if !ok { + return "", ErrNotFound + } + return v, nil +} +func (m *memBackend) Delete(service, user string) error { + delete(m.store, service+"|"+user) + return nil +} + +func TestRoundTrip(t *testing.T) { + k := &Keychain{Backend: &memBackend{}, Service: "vip-go-cli-test"} + + if err := k.Set("rinat", "secret-value"); err != nil { + t.Fatalf("Set: %v", err) + } + got, err := k.Get("rinat") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != "secret-value" { + t.Errorf("Get = %q, want %q", got, "secret-value") + } +} + +func TestGetMissingReturnsNotFound(t *testing.T) { + k := &Keychain{Backend: &memBackend{}, Service: "vip-go-cli-test"} + _, err := k.Get("absent") + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestServiceNamesAreHostSpecific(t *testing.T) { + if got := ServiceNameForHost("https://api.wpvip.com"); got != "vip-next-cli" { + t.Errorf("ServiceNameForHost prod = %q, want %q", got, "vip-next-cli") + } + if got := ServiceNameForHost("https://staging-api.wpvip.com:8443"); got != "vip-next-cli:https---staging-api-wpvip-com-8443" { + t.Errorf("ServiceNameForHost staging = %q, want %q", got, "vip-next-cli:https---staging-api-wpvip-com-8443") + } + if got := LegacyServiceNameForHost("https://api.wpvip.com"); got != "vip-go-cli" { + t.Errorf("LegacyServiceNameForHost prod = %q, want %q", got, "vip-go-cli") + } + if got := LegacyServiceNameForHost("https://staging-api.wpvip.com:8443"); got != "vip-go-cli:https---staging-api-wpvip-com-8443" { + t.Errorf("LegacyServiceNameForHost staging = %q, want %q", got, "vip-go-cli:https---staging-api-wpvip-com-8443") + } +} + +func TestFileBackendRoundTrip(t *testing.T) { + dir := t.TempDir() + b := &FileBackend{Dir: dir} + + if err := b.Set("svc", "user", "secret"); err != nil { + t.Fatalf("Set: %v", err) + } + got, err := b.Get("svc", "user") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != "secret" { + t.Errorf("Get = %q, want %q", got, "secret") + } + if err := b.Delete("svc", "user"); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := b.Get("svc", "user"); !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound after delete, got %v", err) + } +} + +func TestAccountEqualsService(t *testing.T) { + k := &Keychain{Service: "vip-next-cli"} + if k.Account() != "vip-next-cli" { + t.Errorf("Account() = %q, want %q", k.Account(), "vip-next-cli") + } +} + +func TestFileBackendCreatesFileMode0600(t *testing.T) { + dir := t.TempDir() + b := &FileBackend{Dir: dir} + if err := b.Set("svc", "user", "secret"); err != nil { + t.Fatalf("Set: %v", err) + } + info, err := os.Stat(b.path()) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("file mode = %o, want 0600", info.Mode().Perm()) + } +} diff --git a/internal/rechallenge/browser.go b/internal/rechallenge/browser.go new file mode 100644 index 000000000..36b5eec00 --- /dev/null +++ b/internal/rechallenge/browser.go @@ -0,0 +1,16 @@ +package rechallenge + +import ( + "log/slog" + + "github.com/pkg/browser" +) + +// OpenBrowser tries to open url in the user's default browser. Errors are +// swallowed (logged at debug level) — they're not actionable for the CLI. +// Mirrors src/lib/rechallenge/open-browser.ts. +func OpenBrowser(url string) { + if err := browser.OpenURL(url); err != nil { + slog.Debug("rechallenge.OpenBrowser failed", "err", err, "url", url) + } +} diff --git a/internal/rechallenge/client.go b/internal/rechallenge/client.go new file mode 100644 index 000000000..a976a26c9 --- /dev/null +++ b/internal/rechallenge/client.go @@ -0,0 +1,198 @@ +package rechallenge + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + json "encoding/json/v2" + + "github.com/Automattic/vip/internal/httpproxy" +) + +// Client speaks the Parker REST protocol. APIHost should NOT include a trailing +// slash; paths supplied to its methods come from extensions.rechallenge and +// already start with "/". +type Client struct { + APIHost string + BearerToken string + HTTP *http.Client +} + +func (c *Client) httpClient() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + // A bare &http.Client{} would inherit http.DefaultTransport's proxy + // policy, which is the inverse of Node's. Step-up requests carry the + // bearer token and mint an elevated one. See internal/httpproxy. + return httpproxy.ClientWithTimeout(30 * time.Second) +} + +type CreateSessionInput struct { + Path string + RequestedOperation string +} + +func (c *Client) CreateSession(in CreateSessionInput) (*Session, error) { + body, err := json.Marshal(map[string]string{ + "clientType": ClientType, + "requestedOperation": in.RequestedOperation, + }) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", c.absoluteURL(in.Path), bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Idempotency-Key", randomUUID()) + if err := c.attachAuthorization(req); err != nil { + return nil, err + } + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if !is2xx(resp.StatusCode) { + return nil, c.httpErrorFromResponse(resp, in.RequestedOperation) + } + var s Session + if err := decodeJSON(resp.Body, &s); err != nil { + return nil, err + } + return &s, nil +} + +type GetSessionStatusInput struct { + Template string + ChallengeID string + Scope string +} + +func (c *Client) GetSessionStatus(in GetSessionStatusInput) (*SessionStatus, error) { + path := fillTemplate(in.Template, in.ChallengeID) + req, err := http.NewRequest("GET", c.absoluteURL(path), nil) + if err != nil { + return nil, err + } + if err := c.attachAuthorization(req); err != nil { + return nil, err + } + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if !is2xx(resp.StatusCode) { + return nil, c.httpErrorFromResponse(resp, in.Scope) + } + var ss SessionStatus + if err := decodeJSON(resp.Body, &ss); err != nil { + return nil, err + } + return &ss, nil +} + +type ExchangeInput struct { + Template string + ChallengeID string + Scope string +} + +func (c *Client) Exchange(in ExchangeInput) (*ExchangeResponse, error) { + path := fillTemplate(in.Template, in.ChallengeID) + req, err := http.NewRequest("POST", c.absoluteURL(path), nil) + if err != nil { + return nil, err + } + if err := c.attachAuthorization(req); err != nil { + return nil, err + } + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if !is2xx(resp.StatusCode) { + return nil, c.httpErrorFromResponse(resp, in.Scope) + } + var er ExchangeResponse + if err := decodeJSON(resp.Body, &er); err != nil { + return nil, err + } + return &er, nil +} + +func (c *Client) attachAuthorization(req *http.Request) error { + if c.BearerToken == "" { + return nil + } + apiURL, err := url.Parse(c.APIHost) + if err != nil { + return fmt.Errorf("parse rechallenge API host: %w", err) + } + if !strings.EqualFold(req.URL.Scheme, apiURL.Scheme) || !strings.EqualFold(req.URL.Host, apiURL.Host) { + return fmt.Errorf("refusing cross-origin rechallenge request to %s://%s", req.URL.Scheme, req.URL.Host) + } + req.Header.Set("Authorization", "Bearer "+c.BearerToken) + return nil +} + +// absoluteURL combines APIHost with the path UNLESS the path is already absolute. +// Parker templates may be returned as relative paths or full URLs. Authenticated +// requests accept full URLs only when attachAuthorization confirms they are on +// the same origin as APIHost. +func (c *Client) absoluteURL(path string) string { + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + return c.APIHost + path +} + +func fillTemplate(template, challengeID string) string { + return strings.ReplaceAll(template, "{challengeId}", url.PathEscape(challengeID)) +} + +func is2xx(code int) bool { return code >= 200 && code < 300 } + +// httpErrorFromResponse turns a non-2xx Parker response into an error carrying +// the server's own text — that text is the whole diagnosis when step-up fails. +// +// It is redacted at birth rather than at the point of display: the error is +// surfaced to the user, written to CI logs, and shipped to telemetry by +// main.go's exit hook, and Parker echoes request context (including the +// Authorization header we just sent) into some error payloads. Redacting here +// means no future consumer has to remember to. +func (c *Client) httpErrorFromResponse(resp *http.Response, scope string) error { + body, _ := io.ReadAll(resp.Body) + return NewHttpError(resp.StatusCode, RedactSecrets(string(body), c.BearerToken), scope) +} + +func decodeJSON(r io.Reader, v any) error { + body, err := io.ReadAll(r) + if err != nil { + return err + } + return json.Unmarshal(body, v) +} + +func randomUUID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "" + } + // RFC 4122 v4 layout: set version + variant nibbles. + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + h := hex.EncodeToString(b) + return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32] +} diff --git a/internal/rechallenge/client_test.go b/internal/rechallenge/client_test.go new file mode 100644 index 000000000..9a8b99549 --- /dev/null +++ b/internal/rechallenge/client_test.go @@ -0,0 +1,169 @@ +package rechallenge + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + json "encoding/json/v2" +) + +func TestClientCreateSession(t *testing.T) { + var gotMethod, gotPath, gotIdem, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotIdem = r.Header.Get("Idempotency-Key") + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"challengeId":"abc","status":"pending","verificationUrl":"https://x/v/abc","pollIntervalSeconds":2,"expiresAt":"2026-06-05T12:00:00Z"}`)) + })) + defer srv.Close() + c := &Client{APIHost: srv.URL, HTTP: srv.Client()} + s, err := c.CreateSession(CreateSessionInput{ + Path: "/p/v2/cli/sessions", + RequestedOperation: "updateDefensiveModeStatus", + }) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if gotMethod != "POST" { + t.Errorf("method = %q, want POST", gotMethod) + } + if gotPath != "/p/v2/cli/sessions" { + t.Errorf("path = %q", gotPath) + } + if gotIdem == "" { + t.Error("Idempotency-Key header must be set") + } + var body map[string]string + if err := json.Unmarshal([]byte(gotBody), &body); err != nil { + t.Fatalf("body parse: %v", err) + } + if body["clientType"] != "cli" { + t.Errorf("clientType = %q", body["clientType"]) + } + if body["requestedOperation"] != "updateDefensiveModeStatus" { + t.Errorf("requestedOperation = %q", body["requestedOperation"]) + } + if s.ChallengeID != "abc" || s.Status != StatusPending { + t.Errorf("session decode bad: %+v", s) + } +} + +func TestClientGetSessionStatus(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Write([]byte(`{"challengeId":"abc","status":"verified","expiresAt":"2026-06-05T12:00:00Z","provider":"passkeys","pollIntervalSeconds":2}`)) + })) + defer srv.Close() + c := &Client{APIHost: srv.URL, HTTP: srv.Client()} + ss, err := c.GetSessionStatus(GetSessionStatusInput{ + Template: srv.URL + "/p/v2/cli/sessions/{challengeId}", + ChallengeID: "abc", + Scope: "x", + }) + if err != nil { + t.Fatalf("GetSessionStatus: %v", err) + } + if gotPath != "/p/v2/cli/sessions/abc" { + t.Errorf("path = %q", gotPath) + } + if ss.Status != StatusVerified || ss.Provider != "passkeys" { + t.Errorf("status decode bad: %+v", ss) + } +} + +func TestClientGetSessionStatusURLEncodesChallengeID(t *testing.T) { + // Use RequestURI (the raw URI as sent over the wire). r.URL.Path is the + // decoded form, which can't distinguish the encoded slash from a literal one. + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.RequestURI + w.Write([]byte(`{"challengeId":"a/b","status":"pending","expiresAt":"2026-06-05T12:00:00Z","pollIntervalSeconds":2}`)) + })) + defer srv.Close() + c := &Client{APIHost: srv.URL, HTTP: srv.Client()} + _, err := c.GetSessionStatus(GetSessionStatusInput{ + Template: srv.URL + "/p/v2/cli/sessions/{challengeId}", + ChallengeID: "a/b", + }) + if err != nil { + t.Fatalf("GetSessionStatus: %v", err) + } + if !strings.Contains(gotPath, "a%2Fb") { + t.Errorf("path %q must URL-encode challengeId", gotPath) + } +} + +func TestClientExchange(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"elevatedToken":{"token":"elev","expiresAt":"2026-06-05T13:00:00Z","purpose":"u"}}`)) + })) + defer srv.Close() + c := &Client{APIHost: srv.URL, HTTP: srv.Client()} + res, err := c.Exchange(ExchangeInput{ + Template: srv.URL + "/p/v2/cli/sessions/{challengeId}/elevated-token", + ChallengeID: "abc", + }) + if err != nil { + t.Fatalf("Exchange: %v", err) + } + if res.ElevatedToken.Token != "elev" { + t.Errorf("token = %q", res.ElevatedToken.Token) + } +} + +func TestClientHttpErrorOnNon2xx(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(503) + w.Write([]byte("service unavailable")) + })) + defer srv.Close() + c := &Client{APIHost: srv.URL, HTTP: srv.Client()} + _, err := c.CreateSession(CreateSessionInput{ + Path: "/x", + RequestedOperation: "u", + }) + if err == nil { + t.Fatal("expected error on 503") + } + var herr *HttpError + if !errors.As(err, &herr) { + t.Fatalf("err is %T, want *HttpError", err) + } + if herr.StatusCode() != 503 { + t.Errorf("statusCode = %d, want 503", herr.StatusCode()) + } +} + +func TestClientRejectsCrossOriginRechallengeURL(t *testing.T) { + var foreignHits atomic.Int32 + foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + foreignHits.Add(1) + w.Write([]byte(`{"challengeId":"abc","status":"pending","expiresAt":"2026-06-05T12:00:00Z","pollIntervalSeconds":2}`)) + })) + defer foreign.Close() + + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer api.Close() + + c := &Client{APIHost: api.URL, BearerToken: "primary-token", HTTP: api.Client()} + _, err := c.GetSessionStatus(GetSessionStatusInput{ + Template: foreign.URL + "/p/v2/cli/sessions/{challengeId}", + ChallengeID: "abc", + Scope: "updateDefensiveModeStatus", + }) + if err == nil { + t.Fatal("expected cross-origin rechallenge URL to be rejected") + } + if got := foreignHits.Load(); got != 0 { + t.Errorf("foreign server hits = %d, want 0", got) + } +} diff --git a/internal/rechallenge/errors.go b/internal/rechallenge/errors.go new file mode 100644 index 000000000..896a375ff --- /dev/null +++ b/internal/rechallenge/errors.go @@ -0,0 +1,137 @@ +package rechallenge + +import "fmt" + +// Error is the base rechallenge error. Specific failure modes carry one as +// their `base` field and expose it via Unwrap so errors.As(err, &*Error{}) +// recognizes the family. Naming note: we can't embed *Error anonymously +// because the type name and the Error() method collide. +type Error struct { + msg string + scope string +} + +func (e *Error) Error() string { return e.msg } +func (e *Error) Scope() string { return e.scope } + +// UnsupportedVersionError — server requested a version this CLI doesn't speak. +type UnsupportedVersionError struct { + base Error + version string +} + +func NewUnsupportedVersionError(version, scope string) *UnsupportedVersionError { + return &UnsupportedVersionError{ + base: Error{ + msg: fmt.Sprintf( + "Server requested rechallenge version %q but this CLI only supports %s. Update vip-cli.", + version, Version, + ), + scope: scope, + }, + version: version, + } +} + +func (e *UnsupportedVersionError) Error() string { return e.base.msg } +func (e *UnsupportedVersionError) Scope() string { return e.base.scope } +func (e *UnsupportedVersionError) Version() string { return e.version } +func (e *UnsupportedVersionError) Unwrap() error { return &e.base } + +// TerminalError — session ended in a non-verified terminal state. +type TerminalError struct { + base Error + status Status +} + +func NewTerminalError(status Status, scope, detail string) *TerminalError { + msg := fmt.Sprintf("Step-up verification did not complete (status=%s)", status) + if detail != "" { + msg += ": " + detail + } + msg += "." + return &TerminalError{ + base: Error{msg: msg, scope: scope}, + status: status, + } +} + +func (e *TerminalError) Error() string { return e.base.msg } +func (e *TerminalError) Scope() string { return e.base.scope } +func (e *TerminalError) Status() Status { return e.status } +func (e *TerminalError) Unwrap() error { return &e.base } + +// InteractionRequiredError — a step-up challenge was raised in a session where +// no human can answer it. Returned INSTEAD of opening a verification session, +// because polling one to expiry is an unbounded block in exactly the context +// (CI, cron, a piped script) that can least afford it. +// +// Mirrors RechallengeInteractionRequiredError in src/lib/rechallenge/errors.ts. +// The wording differs on one point: Node offers `--rechallenge-wait` as well as +// the environment variable; vip-next has only the environment variable, because +// the flag has no cobra registration to land on and advertising it would be a +// promise the binary does not keep. +type InteractionRequiredError struct { + base Error +} + +func NewInteractionRequiredError(scope string) *InteractionRequiredError { + return &InteractionRequiredError{ + base: Error{ + msg: fmt.Sprintf( + "Step-up verification is required for %s, but this is a non-interactive session, "+ + "so the challenge cannot be approved. Re-run the command interactively, or set "+ + "%s=1 to print the verification URL and wait while you complete it on another "+ + "device. An approval completed interactively is cached, so a later "+ + "non-interactive run of the same operation reuses it until it expires.", + scope, WaitEnvVar, + ), + scope: scope, + }, + } +} + +func (e *InteractionRequiredError) Error() string { return e.base.msg } +func (e *InteractionRequiredError) Scope() string { return e.base.scope } +func (e *InteractionRequiredError) Unwrap() error { return &e.base } + +// AbortedError — user cancelled the flow (signal or interactive cancel). +type AbortedError struct { + base Error +} + +func NewAbortedError(scope string) *AbortedError { + return &AbortedError{ + base: Error{msg: "Step-up verification was cancelled.", scope: scope}, + } +} + +func (e *AbortedError) Error() string { return e.base.msg } +func (e *AbortedError) Scope() string { return e.base.scope } +func (e *AbortedError) Unwrap() error { return &e.base } + +// HttpError — Parker REST endpoint returned a non-2xx response. +type HttpError struct { + base Error + statusCode int + bodyText string +} + +func NewHttpError(statusCode int, bodyText, scope string) *HttpError { + return &HttpError{ + base: Error{ + msg: fmt.Sprintf( + "Step-up verification request failed (HTTP %d): %s", statusCode, bodyText, + ), + scope: scope, + }, + statusCode: statusCode, + bodyText: bodyText, + } +} + +func (e *HttpError) Error() string { return e.base.msg } +func (e *HttpError) Scope() string { return e.base.scope } +func (e *HttpError) StatusCode() int { return e.statusCode } +func (e *HttpError) BodyText() string { return e.bodyText } +func (e *HttpError) Unwrap() error { return &e.base } diff --git a/internal/rechallenge/errors_test.go b/internal/rechallenge/errors_test.go new file mode 100644 index 000000000..23921230e --- /dev/null +++ b/internal/rechallenge/errors_test.go @@ -0,0 +1,81 @@ +package rechallenge + +import ( + "errors" + "strings" + "testing" +) + +func TestUnsupportedVersionError(t *testing.T) { + err := NewUnsupportedVersionError("v3", "doThing") + if !strings.Contains(err.Error(), "v3") || !strings.Contains(err.Error(), "v2") { + t.Errorf("message = %q; want both v3 and v2 mentioned", err.Error()) + } + if err.Scope() != "doThing" { + t.Errorf("Scope = %q, want doThing", err.Scope()) + } + var rerr *Error + if !errors.As(err, &rerr) { + t.Error("errors.As must match base *Error") + } +} + +func TestTerminalError(t *testing.T) { + err := NewTerminalError(StatusFailed, "doThing", "user rejected") + if !strings.Contains(err.Error(), "failed") { + t.Errorf("message = %q must include status", err.Error()) + } + if !strings.Contains(err.Error(), "user rejected") { + t.Errorf("message = %q must include detail", err.Error()) + } + if err.Status() != StatusFailed { + t.Errorf("Status() = %q", err.Status()) + } +} + +func TestTerminalErrorWithoutDetail(t *testing.T) { + err := NewTerminalError(StatusExpired, "doThing", "") + if !strings.Contains(err.Error(), "expired") { + t.Errorf("message = %q must include status", err.Error()) + } + // Format is "Step-up verification did not complete (status=expired)." + // when detail is empty — no ":" should appear. + if strings.Count(err.Error(), ":") != 0 { + t.Errorf("message = %q must not include ':' when detail is empty", err.Error()) + } +} + +func TestAbortedError(t *testing.T) { + err := NewAbortedError("doThing") + if !strings.Contains(err.Error(), "cancelled") { + t.Errorf("message = %q must include 'cancelled'", err.Error()) + } +} + +func TestHttpError(t *testing.T) { + err := NewHttpError(503, "service unavailable", "doThing") + if err.StatusCode() != 503 { + t.Errorf("StatusCode = %d, want 503", err.StatusCode()) + } + if err.BodyText() != "service unavailable" { + t.Errorf("BodyText = %q", err.BodyText()) + } + if !strings.Contains(err.Error(), "503") { + t.Errorf("message must include status code") + } +} + +func TestErrorIs(t *testing.T) { + for _, err := range []error{ + NewUnsupportedVersionError("v3", "s"), + NewTerminalError(StatusFailed, "s", ""), + NewAbortedError("s"), + NewHttpError(500, "x", "s"), + NewInteractionRequiredError("s"), + } { + var base *Error + if !errors.As(err, &base) { + t.Errorf("errors.As(*Error) failed for %T", err) + } + } +} diff --git a/internal/rechallenge/flow.go b/internal/rechallenge/flow.go new file mode 100644 index 000000000..37975f8ab --- /dev/null +++ b/internal/rechallenge/flow.go @@ -0,0 +1,224 @@ +package rechallenge + +import ( + "context" + "fmt" + "io" + "os" + "time" +) + +// Tracker is the minimal telemetry interface the runner needs. +type Tracker interface { + Track(name string, props map[string]any) +} + +// Runner orchestrates one step-up flow. All side-effect dependencies are +// injectable for testing. +type Runner struct { + Client *Client + Tracker Tracker + TokenCache *TokenCache + + // Stdout receives the user-facing verification prompt. Defaults to os.Stderr + // (Node's flow.ts uses console.warn so we mirror to stderr). + Stdout io.Writer + + // OpenURL is called to open the browser when Interactive is true. + // Defaults to OpenBrowser. + OpenURL func(url string) + + // Sleep is called between polls. ctx-aware; tests inject a no-op or + // ctx-blocking variant. + Sleep func(ctx context.Context, d time.Duration) error +} + +// MinPollInterval floors the server-supplied poll interval. A missing, zero, or +// negative pollIntervalSeconds used to produce a zero sleep, i.e. an +// unthrottled status-poll loop against Parker for the life of the session. +// Matches MIN_POLL_INTERVAL_SECONDS in src/lib/rechallenge/flow.ts. +const MinPollInterval = 2 * time.Second + +// RunInput contains everything the runner needs for one invocation. +type RunInput struct { + RequestedOperation string + Extension Extension + Interactive bool + + // Wait opts a non-interactive caller back in to polling. Without it a + // step-up challenge raised outside a TTY fails immediately rather than + // blocking on an approval nobody present can give. See + // ShouldWaitForRechallenge. + Wait bool +} + +func (r *Runner) writer() io.Writer { + if r.Stdout != nil { + return r.Stdout + } + return os.Stderr +} + +func (r *Runner) sleep(ctx context.Context, d time.Duration) error { + if r.Sleep != nil { + return r.Sleep(ctx, d) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(d): + return nil + } +} + +func (r *Runner) openURL(url string) { + if r.OpenURL != nil { + r.OpenURL(url) + return + } + OpenBrowser(url) +} + +// bearerToken returns the credential this runner authenticates with, for use as +// a redaction secret. Nil-safe: callers redact even when there is no token to +// redact, so the JWT pattern still applies. +func (r *Runner) bearerToken() string { + if r.Client == nil { + return "" + } + return r.Client.BearerToken +} + +func (r *Runner) track(name string, props map[string]any) { + if r.Tracker == nil { + return + } + r.Tracker.Track(name, props) +} + +// Run executes the step-up flow and returns the elevated token on success. +func (r *Runner) Run(ctx context.Context, in RunInput) (*ElevatedToken, error) { + scope := in.RequestedOperation + + if in.Extension.Version != Version { + return nil, NewUnsupportedVersionError(in.Extension.Version, scope) + } + + r.track("rechallenge_required", map[string]any{ + "scope": scope, + "clientType": ClientType, + }) + + // Fail before creating the session, not after. A step-up challenge in a + // non-interactive session is unsatisfiable by construction: the approval + // happens in a browser and there is nobody at one. Polling it anyway meant + // a CI job blocked for the entire verification window and then failed with + // "expired" — the worst of both, a long wait and no explanation. Minting + // the session first would also leave a challenge on the server that can + // only ever expire unused. + if !in.Interactive && !in.Wait { + r.track("rechallenge_interaction_required", map[string]any{"scope": scope}) + return nil, NewInteractionRequiredError(scope) + } + + session, err := r.Client.CreateSession(CreateSessionInput{ + Path: in.Extension.CreateSessionPath, + RequestedOperation: scope, + }) + if err != nil { + return nil, err + } + r.track("rechallenge_session_created", map[string]any{"scope": scope}) + + if in.Interactive { + r.openURL(session.VerificationURL) + fmt.Fprintf(r.writer(), + "⚠ Step-up verification required for %s.\n Opened %s\n If your browser did not open, paste the URL above. Expires at %s.\n", + scope, session.VerificationURL, session.ExpiresAt.Format(time.RFC3339), + ) + } else { + fmt.Fprintf(r.writer(), + "Step-up verification required for %s. Complete it at: %s (expires at %s).\n", + scope, session.VerificationURL, session.ExpiresAt.Format(time.RFC3339), + ) + } + + // A session with no usable expiry is not a session we can wait on: the + // old `!deadline.IsZero()` guard turned a missing or unparseable + // expiresAt into "no deadline at all", so the loop below polled forever + // with nothing able to stop it but SIGINT. Node refuses the same case + // outright (flow.ts:93). + deadline := session.ExpiresAt + if deadline.IsZero() { + return nil, NewTerminalError(StatusExpired, scope, + "server did not return a usable expiresAt for the verification session") + } + + pollInterval := time.Duration(session.PollIntervalSeconds) * time.Second + if pollInterval < MinPollInterval { + pollInterval = MinPollInterval + } + + for { + // Check context cancellation BEFORE sleeping so a pre-cancelled ctx + // returns AbortedError without polling. + if ctx.Err() != nil { + return nil, NewAbortedError(scope) + } + if err := r.sleep(ctx, pollInterval); err != nil { + return nil, NewAbortedError(scope) + } + if time.Now().After(deadline) { + return nil, NewTerminalError(StatusExpired, scope, "session window elapsed before completion") + } + + ss, err := r.Client.GetSessionStatus(GetSessionStatusInput{ + Template: in.Extension.StatusPathTemplate, + ChallengeID: session.ChallengeID, + Scope: scope, + }) + if err != nil { + return nil, err + } + + if !ss.Status.IsTerminal() { + continue + } + + if ss.Status == StatusVerified { + provider := ss.Provider + if provider == "" { + provider = "unknown" + } + r.track("rechallenge_verified", map[string]any{ + "scope": scope, + "provider": provider, + }) + exch, err := r.Client.Exchange(ExchangeInput{ + Template: in.Extension.ExchangePathTemplate, + ChallengeID: session.ChallengeID, + Scope: scope, + }) + if err != nil { + return nil, err + } + r.track("rechallenge_exchanged", map[string]any{"scope": scope}) + tok := exch.ElevatedToken + tok.HeaderName = in.Extension.ElevatedHeaderName + if r.TokenCache != nil { + _ = r.TokenCache.Set(scope, tok) + } + return &tok, nil + } + + // Non-verified terminal status. + r.track(fmt.Sprintf("rechallenge_%s", ss.Status), map[string]any{"scope": scope}) + detail := "" + if ss.StatusReason != nil { + // Server-controlled text on its way to the terminal, CI logs, and + // the telemetry exit hook. + detail = RedactSecrets(ss.StatusReason.Message, r.bearerToken()) + } + return nil, NewTerminalError(ss.Status, scope, detail) + } +} diff --git a/internal/rechallenge/flow_noninteractive_test.go b/internal/rechallenge/flow_noninteractive_test.go new file mode 100644 index 000000000..5494ba13f --- /dev/null +++ b/internal/rechallenge/flow_noninteractive_test.go @@ -0,0 +1,285 @@ +package rechallenge + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// pendingForeverServer answers createSession with a session that never leaves +// "pending" and does not expire for an hour. Any flow that decides to poll it +// runs until the watchdog fires, which is exactly the CI hang under test. +func pendingForeverServer(t *testing.T, sessionsCreated *int32) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + hour := func() string { return time.Now().Add(time.Hour).Format(time.RFC3339) } + mux.HandleFunc("/p/sessions", func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(sessionsCreated, 1) + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v/c1","pollIntervalSeconds":0,"expiresAt":"` + hour() + `"}`)) + }) + mux.HandleFunc("/p/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","expiresAt":"` + hour() + `","pollIntervalSeconds":0}`)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func testExtension(base string) Extension { + return Extension{ + Version: Version, + CreateSessionPath: "/p/sessions", + StatusPathTemplate: base + "/p/sessions/{challengeId}", + ExchangePathTemplate: base + "/p/sessions/{challengeId}/exchange", + ElevatedHeaderName: "x-elevated-token", + } +} + +// runAsync runs the flow on a goroutine and returns a channel carrying the +// result. The caller MUST select against a watchdog: a regression here is an +// infinite poll loop, and a plain synchronous call would express it as a stuck +// CI job instead of a red build. +func runAsync(ctx context.Context, r *Runner, in RunInput) <-chan error { + done := make(chan error, 1) + go func() { _, err := r.Run(ctx, in); done <- err }() + return done +} + +// TestFlowNonInteractiveFailsFastInsteadOfPolling pins the CI-hang fix. +// +// Before: a step-up challenge raised in a --non-interactive session created a +// verification session and polled it until the session expired — nobody can +// approve a browser challenge in CI, so the command blocked for the whole +// session window and only then failed. After: it fails immediately, and never +// creates the unapprovable session in the first place. +// +// Node parity: src/lib/rechallenge/flow.ts:56 (`if (!interactive && !wait)`). +func TestFlowNonInteractiveFailsFastInsteadOfPolling(t *testing.T) { + var sessionsCreated int32 + srv := pendingForeverServer(t, &sessionsCreated) + + // Cancellable so a REGRESSION stops polling when the watchdog fires + // instead of leaking a hot goroutine into the rest of the package's tests. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tr := &fakeTracker{} + r := &Runner{ + Client: &Client{APIHost: srv.URL, HTTP: srv.Client()}, + Tracker: tr, + TokenCache: newTestCache(), + Stdout: io.Discard, + Sleep: func(ctx context.Context, _ time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(10 * time.Millisecond): + return nil + } + }, + } + + done := runAsync(ctx, r, RunInput{ + RequestedOperation: "updateDefensiveModeStatus", + Interactive: false, + Extension: testExtension(srv.URL), + }) + + select { + case err := <-done: + var ire *InteractionRequiredError + if !errors.As(err, &ire) { + t.Fatalf("err = %T (%v), want *InteractionRequiredError", err, err) + } + // The message has to say what happened AND what to do about it — + // a bare "permission denied" is what sent people spelunking. + for _, want := range []string{ + "updateDefensiveModeStatus", + "non-interactive", + "VIP_RECHALLENGE_WAIT=1", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("error text must mention %q; got %q", want, err.Error()) + } + } + case <-time.After(5 * time.Second): + t.Fatal("Run did not return within 5s in a non-interactive session: " + + "step-up is polling a challenge nobody can approve (this is the CI hang)") + } + + if n := atomic.LoadInt32(&sessionsCreated); n != 0 { + t.Errorf("createSession called %d times; a non-interactive session must not "+ + "mint a verification challenge no human can complete", n) + } + if !containsString(tr.events, "rechallenge_interaction_required") { + t.Errorf("missing rechallenge_interaction_required event; got %v", tr.events) + } +} + +// TestFlowNonInteractiveWaitOptInStillPolls: the fail-fast must stay opt-out-able. +// An operator running headless who can approve on a phone sets +// VIP_RECHALLENGE_WAIT=1 (Node: --rechallenge-wait) and gets the old behavior. +func TestFlowNonInteractiveWaitOptInStillPolls(t *testing.T) { + mux := http.NewServeMux() + hour := func() string { return time.Now().Add(time.Hour).Format(time.RFC3339) } + mux.HandleFunc("/p/sessions", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v/c1","pollIntervalSeconds":0,"expiresAt":"` + hour() + `"}`)) + }) + mux.HandleFunc("/p/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"verified","expiresAt":"` + hour() + `","pollIntervalSeconds":0,"provider":"passkeys"}`)) + }) + mux.HandleFunc("/p/sessions/c1/exchange", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"elevatedToken":{"token":"opaque","expiresAt":"` + time.Now().Add(2*time.Hour).Format(time.RFC3339) + `","purpose":"x"}}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + var out strings.Builder + r := &Runner{ + Client: &Client{APIHost: srv.URL, HTTP: srv.Client()}, + Tracker: &fakeTracker{}, + TokenCache: newTestCache(), + Stdout: &out, + Sleep: func(context.Context, time.Duration) error { return nil }, + } + tok, err := r.Run(context.Background(), RunInput{ + RequestedOperation: "updateDefensiveModeStatus", + Interactive: false, + Wait: true, + Extension: testExtension(srv.URL), + }) + if err != nil { + t.Fatalf("Run with Wait opt-in: %v", err) + } + if tok == nil || tok.Token != "opaque" { + t.Fatalf("token = %+v, want opaque", tok) + } + if !strings.Contains(out.String(), "https://example/v/c1") { + t.Errorf("waiting non-interactive run must print the verification URL; got %q", out.String()) + } +} + +// TestFlowRejectsUnusableDeadline: a session with no (or unparseable) expiresAt +// used to disable the deadline check entirely — `!deadline.IsZero()` meant the +// loop polled forever with nothing to stop it. Node throws immediately +// (flow.ts:93). Watchdogged for the same reason as above. +func TestFlowRejectsUnusableDeadline(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/p/sessions", func(w http.ResponseWriter, r *http.Request) { + // No expiresAt at all -> zero time. + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v/c1","pollIntervalSeconds":0}`)) + }) + mux.HandleFunc("/p/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","pollIntervalSeconds":0}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + r := &Runner{ + Client: &Client{APIHost: srv.URL, HTTP: srv.Client()}, + Tracker: &fakeTracker{}, + TokenCache: newTestCache(), + Stdout: io.Discard, + Sleep: func(ctx context.Context, _ time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(10 * time.Millisecond): + return nil + } + }, + } + done := runAsync(ctx, r, RunInput{ + RequestedOperation: "updateDefensiveModeStatus", + Interactive: true, + Extension: testExtension(srv.URL), + }) + + select { + case err := <-done: + var terr *TerminalError + if !errors.As(err, &terr) { + t.Fatalf("err = %T (%v), want *TerminalError", err, err) + } + if terr.Status() != StatusExpired { + t.Errorf("status = %q, want %q", terr.Status(), StatusExpired) + } + case <-time.After(5 * time.Second): + t.Fatal("Run did not return within 5s for a session with no expiresAt: " + + "the poll loop has no deadline and will never stop") + } +} + +// TestFlowFloorsServerPollInterval: pollIntervalSeconds of 0 (or absent, or +// negative) used to produce a zero sleep, i.e. an unthrottled status-poll loop +// against Parker for the life of the session. Node clamps to 2s +// (flow.ts:24 MIN_POLL_INTERVAL_SECONDS). +func TestFlowFloorsServerPollInterval(t *testing.T) { + for _, tc := range []struct { + name string + body string + }{ + {"zero", `"pollIntervalSeconds":0,`}, + {"absent", ``}, + {"negative", `"pollIntervalSeconds":-5,`}, + {"below floor", `"pollIntervalSeconds":1,`}, + } { + t.Run(tc.name, func(t *testing.T) { + hour := time.Now().Add(time.Hour).Format(time.RFC3339) + mux := http.NewServeMux() + mux.HandleFunc("/p/sessions", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v",` + tc.body + `"expiresAt":"` + hour + `"}`)) + }) + mux.HandleFunc("/p/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"verified","expiresAt":"` + hour + `","pollIntervalSeconds":0,"provider":"p"}`)) + }) + mux.HandleFunc("/p/sessions/c1/exchange", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"elevatedToken":{"token":"t","expiresAt":"` + hour + `","purpose":"x"}}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + var mu sync.Mutex + var slept []time.Duration + r := &Runner{ + Client: &Client{APIHost: srv.URL, HTTP: srv.Client()}, + Tracker: &fakeTracker{}, + TokenCache: newTestCache(), + Stdout: io.Discard, + Sleep: func(_ context.Context, d time.Duration) error { + mu.Lock() + slept = append(slept, d) + mu.Unlock() + return nil + }, + } + if _, err := r.Run(context.Background(), RunInput{ + RequestedOperation: "op", + Interactive: true, + Extension: testExtension(srv.URL), + }); err != nil { + t.Fatalf("Run: %v", err) + } + mu.Lock() + defer mu.Unlock() + if len(slept) == 0 { + t.Fatal("poll loop never slept") + } + for _, d := range slept { + if d < MinPollInterval { + t.Errorf("slept %v, want >= %v (unthrottled polling hammers Parker)", d, MinPollInterval) + } + } + }) + } +} diff --git a/internal/rechallenge/flow_test.go b/internal/rechallenge/flow_test.go new file mode 100644 index 000000000..a64d00d0f --- /dev/null +++ b/internal/rechallenge/flow_test.go @@ -0,0 +1,203 @@ +package rechallenge + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +type fakeTracker struct { + mu sync.Mutex + events []string +} + +func (f *fakeTracker) Track(name string, _ map[string]any) { + f.mu.Lock() + defer f.mu.Unlock() + f.events = append(f.events, name) +} + +func TestFlowUnsupportedVersion(t *testing.T) { + cache := newTestCache() + tr := &fakeTracker{} + r := &Runner{Tracker: tr, TokenCache: cache} + _, err := r.Run(context.Background(), RunInput{ + RequestedOperation: "doThing", + Extension: Extension{ + Version: "v99", + CreateSessionPath: "/x", + StatusPathTemplate: "/x/{challengeId}", + ExchangePathTemplate: "/x/{challengeId}/e", + ElevatedHeaderName: "x-elevated-token", + }, + }) + var ver *UnsupportedVersionError + if !errors.As(err, &ver) { + t.Fatalf("err = %T, want *UnsupportedVersionError", err) + } +} + +func TestFlowHappyPathVerified(t *testing.T) { + pollCount := int32(0) + mux := http.NewServeMux() + mux.HandleFunc("/p/v2/cli/sessions", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v/c1","pollIntervalSeconds":0,"expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `"}`)) + }) + mux.HandleFunc("/p/v2/cli/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + n := atomic.AddInt32(&pollCount, 1) + if n < 2 { + w.Write([]byte(`{"challengeId":"c1","status":"pending","expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `","pollIntervalSeconds":0}`)) + return + } + w.Write([]byte(`{"challengeId":"c1","status":"verified","expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `","pollIntervalSeconds":0,"provider":"passkeys"}`)) + }) + mux.HandleFunc("/p/v2/cli/sessions/c1/exchange", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"elevatedToken":{"token":"opaque","expiresAt":"` + time.Now().Add(2*time.Hour).Format(time.RFC3339) + `","purpose":"doThing"}}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + tr := &fakeTracker{} + cache := newTestCache() + var out bytes.Buffer + openCalled := int32(0) + r := &Runner{ + Client: &Client{APIHost: srv.URL, HTTP: srv.Client()}, + Tracker: tr, + TokenCache: cache, + Stdout: &out, + OpenURL: func(string) { atomic.AddInt32(&openCalled, 1) }, + Sleep: func(_ context.Context, _ time.Duration) error { return nil }, + } + + tok, err := r.Run(context.Background(), RunInput{ + RequestedOperation: "doThing", + Interactive: true, + Extension: Extension{ + Version: Version, + CreateSessionPath: "/p/v2/cli/sessions", + StatusPathTemplate: srv.URL + "/p/v2/cli/sessions/{challengeId}", + ExchangePathTemplate: srv.URL + "/p/v2/cli/sessions/{challengeId}/exchange", + ElevatedHeaderName: "x-elevated-token", + }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if tok.Token != "opaque" { + t.Errorf("token = %q", tok.Token) + } + if atomic.LoadInt32(&openCalled) != 1 { + t.Errorf("OpenURL called %d times, want 1", openCalled) + } + wantSubseq := []string{ + "rechallenge_required", + "rechallenge_session_created", + "rechallenge_verified", + "rechallenge_exchanged", + } + for _, w := range wantSubseq { + if !containsString(tr.events, w) { + t.Errorf("missing event %q in %v", w, tr.events) + } + } + if !strings.Contains(out.String(), "https://example/v/c1") { + t.Errorf("stdout missing verification URL: %q", out.String()) + } + // Cache must hold the token under scope. + cached, _ := cache.Get("doThing") + if cached == nil || cached.Token != "opaque" { + t.Errorf("cache.Get = %+v, want token opaque", cached) + } +} + +func TestFlowTerminalCancelled(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/p/v2/cli/sessions", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v","pollIntervalSeconds":0,"expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `"}`)) + }) + mux.HandleFunc("/p/v2/cli/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"cancelled","expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `","pollIntervalSeconds":0,"statusReason":{"code":"user","message":"user cancelled"}}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + r := &Runner{ + Client: &Client{APIHost: srv.URL, HTTP: srv.Client()}, + Tracker: &fakeTracker{}, + TokenCache: newTestCache(), + Sleep: func(_ context.Context, _ time.Duration) error { return nil }, + } + _, err := r.Run(context.Background(), RunInput{ + RequestedOperation: "doThing", + // Interactive: only an interactive (or explicitly waiting) session + // gets as far as polling — see TestFlowNonInteractiveFailsFastInsteadOfPolling. + Interactive: true, + Extension: Extension{ + Version: Version, + CreateSessionPath: "/p/v2/cli/sessions", + StatusPathTemplate: srv.URL + "/p/v2/cli/sessions/{challengeId}", + ExchangePathTemplate: srv.URL + "/p/v2/cli/sessions/{challengeId}/x", + ElevatedHeaderName: "x-elevated-token", + }, + }) + var terr *TerminalError + if !errors.As(err, &terr) { + t.Fatalf("err = %T (%v); want *TerminalError", err, err) + } + if terr.Status() != StatusCancelled { + t.Errorf("Status = %q", terr.Status()) + } + if !strings.Contains(err.Error(), "user cancelled") { + t.Errorf("err must include statusReason detail: %v", err) + } +} + +func TestFlowAbortedByContext(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v","pollIntervalSeconds":1,"expiresAt":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `"}`)) + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + r := &Runner{ + Client: &Client{APIHost: srv.URL, HTTP: srv.Client()}, + Tracker: &fakeTracker{}, + TokenCache: newTestCache(), + Sleep: func(ctx context.Context, _ time.Duration) error { + <-ctx.Done() + return ctx.Err() + }, + } + cancel() // cancel before Run starts polling + _, err := r.Run(ctx, RunInput{ + RequestedOperation: "doThing", + Interactive: true, + Extension: Extension{ + Version: Version, + CreateSessionPath: "/x", + StatusPathTemplate: "/x/{challengeId}", + ExchangePathTemplate: "/x/{challengeId}/e", + ElevatedHeaderName: "x-elevated-token", + }, + }) + var aerr *AbortedError + if !errors.As(err, &aerr) { + t.Errorf("err = %T, want *AbortedError", err) + } +} + +func containsString(haystack []string, needle string) bool { + for _, h := range haystack { + if h == needle { + return true + } + } + return false +} diff --git a/internal/rechallenge/interactive.go b/internal/rechallenge/interactive.go new file mode 100644 index 000000000..23c65c7b9 --- /dev/null +++ b/internal/rechallenge/interactive.go @@ -0,0 +1,90 @@ +package rechallenge + +import ( + "os" + "slices" + "strings" + + "golang.org/x/term" +) + +// IsInteractiveContext returns true when interactive prompts and browser opens +// are appropriate. Mirrors src/lib/rechallenge/flow.ts:isInteractiveContext. +// +// Order: +// 1. VIP_NON_INTERACTIVE=1 → false +// 2. argv contains "--non-interactive" → false +// 3. else stdin-is-tty +// +// Sensed on STDIN for the same reason as appctx.IsInteractive (parity blocker +// B5): an approval has to be typed in, so stdout redirection is irrelevant. +func IsInteractiveContext(argv []string) bool { + return isInteractiveCheck(argv, term.IsTerminal(int(os.Stdin.Fd()))) +} + +// isInteractiveCheck is the testable core. The tty value is injected so +// tests don't depend on whether `go test` is run from a TTY. +func isInteractiveCheck(argv []string, tty bool) bool { + if os.Getenv("VIP_NON_INTERACTIVE") == "1" { + return false + } + if slices.Contains(argv, "--non-interactive") { + return false + } + return tty +} + +// WaitEnvVar opts back in to waiting for a step-up approval in a +// non-interactive session. Named as a constant because the error text that +// tells the user about it must not be able to drift from the variable actually +// read. +const WaitEnvVar = "VIP_RECHALLENGE_WAIT" + +// WaitFlag is the command-line half of the same opt-in. Registered by the +// commands that can trip step-up (see NewDefensiveModeCmd), matching where +// Node registers it — src/bin/vip-defensive-mode-{enable,disable,configure}.js. +const WaitFlag = "--rechallenge-wait" + +// ShouldWaitForRechallenge reports whether the caller has explicitly asked to +// block on a step-up challenge despite being non-interactive — the case where +// an operator running headless will approve on a phone. +// +// Without this, a non-interactive step-up fails fast (see +// NewInteractionRequiredError): the default has to be "fail", because the +// common non-interactive caller is CI, which cannot approve anything and would +// otherwise block until the verification session expires. +// +// Mirrors src/lib/rechallenge/flow.ts:185, including its argv scan and reason +// for it: the step-up middleware is built once at startup and cannot read a +// command's parsed options, so the flag is read from the raw command line even +// though cobra also parses it. +func ShouldWaitForRechallenge() bool { + return shouldWaitCheck(os.Args) +} + +// shouldWaitCheck is the testable core; argv is injected so tests don't have to +// mutate os.Args. +func shouldWaitCheck(argv []string) bool { + if os.Getenv(WaitEnvVar) == "1" { + return true + } + for _, item := range argv { + if item == WaitFlag { + return true + } + // `--rechallenge-wait=` counts unless the value is a negation, + // so `--rechallenge-wait=false` does not silently mean true. + if value, ok := strings.CutPrefix(item, WaitFlag+"="); ok && !isNegation(value) { + return true + } + } + return false +} + +func isNegation(value string) bool { + switch strings.ToLower(value) { + case "0", "false", "no", "off": + return true + } + return false +} diff --git a/internal/rechallenge/interactive_test.go b/internal/rechallenge/interactive_test.go new file mode 100644 index 000000000..6199e7c2f --- /dev/null +++ b/internal/rechallenge/interactive_test.go @@ -0,0 +1,106 @@ +package rechallenge + +import ( + "os" + "strings" + "testing" + + "github.com/creack/pty" +) + +// Same stdout-vs-stdin sensor bug as appctx.IsInteractive (parity blocker B5). +// This one is live: gql/rechallenge.go:111 and main.go:143 use it to decide +// whether a step-up approval can be prompted for, so a redirected stdout made +// step-up give up on a perfectly interactive terminal. +func TestIsInteractiveContextSensesStdinNotStdout(t *testing.T) { + ptmx, tty, err := pty.Open() + if err != nil { + t.Skipf("pty unavailable: %v", err) + } + defer func() { _ = ptmx.Close(); _ = tty.Close() }() + redirected, err := os.CreateTemp(t.TempDir(), "redirected") + if err != nil { + t.Fatal(err) + } + defer redirected.Close() + + origIn, origOut := os.Stdin, os.Stdout + os.Stdin, os.Stdout = tty, redirected + defer func() { os.Stdin, os.Stdout = origIn, origOut }() + + if !IsInteractiveContext(nil) { + t.Error("stdin is a TTY and only stdout is redirected: step-up must still be promptable") + } +} + +// ShouldWaitForRechallenge is the opt-out from the non-interactive fail-fast: +// an operator who can approve on another device asks for the old polling +// behavior explicitly. Mirrors the environment half of +// src/lib/rechallenge/flow.ts:shouldWaitForRechallenge. +func TestShouldWaitForRechallenge(t *testing.T) { + tests := []struct { + name string + argv []string + env string + setEnv bool + want bool + }{ + {name: "default off", want: false}, + {name: "env =1", env: "1", setEnv: true, want: true}, + {name: "env =0 stays off", env: "0", setEnv: true, want: false}, + {name: "env =true is not 1", env: "true", setEnv: true, want: false}, + {name: "env empty stays off", env: "", setEnv: true, want: false}, + {name: "bare flag", argv: []string{"defensive-mode", "enable", "--rechallenge-wait"}, want: true}, + {name: "flag=true", argv: []string{"--rechallenge-wait=true"}, want: true}, + {name: "flag=false", argv: []string{"--rechallenge-wait=false"}, want: false}, + {name: "flag=0", argv: []string{"--rechallenge-wait=0"}, want: false}, + {name: "flag=OFF", argv: []string{"--rechallenge-wait=OFF"}, want: false}, + {name: "similar flag does not count", argv: []string{"--rechallenge-waiting"}, want: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.setEnv { + t.Setenv(WaitEnvVar, tc.env) + } + if got := shouldWaitCheck(tc.argv); got != tc.want { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} + +// The message that tells the user how to opt in must name the variable the code +// actually reads. These drift apart the moment they are two independent +// strings. +func TestInteractionRequiredErrorNamesTheRealEnvVar(t *testing.T) { + err := NewInteractionRequiredError("updateDefensiveModeStatus") + if !strings.Contains(err.Error(), WaitEnvVar+"=1") { + t.Errorf("error must tell the user the opt-in that exists; got %q", err.Error()) + } +} + +func TestIsInteractiveContext(t *testing.T) { + tests := []struct { + name string + argv []string + env map[string]string + tty bool + want bool + }{ + {"tty + no overrides", nil, nil, true, true}, + {"non-tty", nil, nil, false, false}, + {"VIP_NON_INTERACTIVE=1", nil, map[string]string{"VIP_NON_INTERACTIVE": "1"}, true, false}, + {"--non-interactive in argv", []string{"defensive-mode", "enable", "--non-interactive"}, nil, true, false}, + {"VIP_NON_INTERACTIVE empty doesn't disable", nil, map[string]string{"VIP_NON_INTERACTIVE": ""}, true, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + for k, v := range tc.env { + t.Setenv(k, v) + } + if got := isInteractiveCheck(tc.argv, tc.tty); got != tc.want { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/rechallenge/redact.go b/internal/rechallenge/redact.go new file mode 100644 index 000000000..b759f19ae --- /dev/null +++ b/internal/rechallenge/redact.go @@ -0,0 +1,36 @@ +package rechallenge + +import ( + "regexp" + "strings" +) + +// jwtInText matches a JSON Web Token by its header segment rather than by the +// generic three-dotted-segments shape. +// +// Every JWT header is base64url-encoded JSON, so it always begins with the +// encoding of `{"` — "eyJ". Anchoring on that is what keeps this usable: the +// unanchored `seg.seg.seg` form that internal/parity uses is fine for a test +// harness, but here it would also swallow ordinary dotted hostnames +// ("parker-service.production.example") out of the very error text we are +// adding for diagnosis. The authoritative protection is the explicit secret +// list below — this pattern is the net for a token we were never handed. +var jwtInText = regexp.MustCompile(`eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*`) + +// RedactSecrets strips credentials from server-controlled text before it is +// shown, logged, or shipped. +// +// Every string that passes through here is on its way somewhere durable: the +// user's terminal, a CI log, and cmd/vip-next/main.go's exit hook, which posts +// error text to the telemetry endpoint. Parker echoes request context into some +// error payloads, so a response body can carry back the Authorization header we +// sent it. Pass every credential in scope as a secret; an empty secret is +// ignored so a zero-valued token cannot blank the whole message. +func RedactSecrets(value string, secrets ...string) string { + for _, secret := range secrets { + if secret != "" { + value = strings.ReplaceAll(value, secret, "") + } + } + return jwtInText.ReplaceAllString(value, "") +} diff --git a/internal/rechallenge/redact_test.go b/internal/rechallenge/redact_test.go new file mode 100644 index 000000000..576e6667a --- /dev/null +++ b/internal/rechallenge/redact_test.go @@ -0,0 +1,142 @@ +package rechallenge + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +const fakeBearer = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NSJ9.c2lnbmF0dXJlLWJ5dGVz" + +func TestRedactSecrets(t *testing.T) { + tests := []struct { + name string + in string + secrets []string + absent []string + present []string + }{ + { + name: "known secret is replaced verbatim", + in: "upstream rejected Authorization: Bearer " + fakeBearer, + secrets: []string{fakeBearer}, + absent: []string{fakeBearer}, + present: []string{"upstream rejected"}, + }, + { + name: "a JWT we were never handed is still caught", + in: `{"echo":{"headers":{"authorization":"Bearer eyJhbGciOiJub25lIn0.eyJzdWIiOiJvdGhlciJ9.xyz"}}}`, + secrets: nil, + absent: []string{"eyJhbGciOiJub25lIn0.eyJzdWIiOiJvdGhlciJ9.xyz"}, + }, + { + // The unanchored seg.seg.seg pattern would eat this, gutting the + // diagnosis the surfaced reason exists to provide. + name: "dotted hostnames survive", + in: "step-up provider parker-service.production.example refused the request", + present: []string{"parker-service.production.example"}, + }, + { + name: "empty secret does not blank the whole string", + in: "session window elapsed", + secrets: []string{""}, + present: []string{"session window elapsed"}, + }, + { + name: "ordinary text is untouched", + in: "Step-up verification did not complete (status=cancelled): user cancelled.", + present: []string{"user cancelled"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := RedactSecrets(tc.in, tc.secrets...) + for _, a := range tc.absent { + if strings.Contains(got, a) { + t.Errorf("redacted text still contains %q: %s", a, got) + } + } + for _, p := range tc.present { + if !strings.Contains(got, p) { + t.Errorf("redacted text lost %q: %s", p, got) + } + } + }) + } +} + +// TestHttpErrorRedactsBearerToken: Parker echoes request context into some +// error payloads, and these error strings now reach the user's terminal, CI +// logs, and the telemetry exit hook. The response body must never be able to +// carry the bearer token back out. +func TestHttpErrorRedactsBearerToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + // Worst case: the server reflects the request it received. + w.Write([]byte(`{"error":"forbidden","request":{"authorization":"Bearer ` + + r.Header.Get("Authorization") + `"}}`)) + })) + defer srv.Close() + + c := &Client{APIHost: srv.URL, HTTP: srv.Client(), BearerToken: fakeBearer} + _, err := c.CreateSession(CreateSessionInput{Path: "/x", RequestedOperation: "op"}) + if err == nil { + t.Fatal("want an error for HTTP 403") + } + if strings.Contains(err.Error(), fakeBearer) { + t.Fatalf("bearer token leaked into error text: %s", err.Error()) + } + if !strings.Contains(err.Error(), "forbidden") { + t.Errorf("redaction ate the diagnosis; want the server reason, got: %s", err.Error()) + } + + var herr *HttpError + if !errors.As(err, &herr) { + t.Fatalf("err = %T, want *HttpError", err) + } + if strings.Contains(herr.BodyText(), fakeBearer) { + t.Errorf("bearer token leaked via BodyText(): %s", herr.BodyText()) + } +} + +// TestFlowRedactsStatusReason: statusReason.message is server-controlled text +// that lands in the TerminalError the user sees. +func TestFlowRedactsStatusReason(t *testing.T) { + hour := time.Now().Add(time.Hour).Format(time.RFC3339) + mux := http.NewServeMux() + mux.HandleFunc("/p/sessions", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"pending","verificationUrl":"https://example/v","pollIntervalSeconds":0,"expiresAt":"` + hour + `"}`)) + }) + mux.HandleFunc("/p/sessions/c1", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"challengeId":"c1","status":"failed","expiresAt":"` + hour + + `","pollIntervalSeconds":0,"statusReason":{"code":"x","message":"denied for token ` + fakeBearer + `"}}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + r := &Runner{ + Client: &Client{APIHost: srv.URL, HTTP: srv.Client(), BearerToken: fakeBearer}, + Tracker: &fakeTracker{}, + TokenCache: newTestCache(), + Stdout: new(strings.Builder), + Sleep: func(context.Context, time.Duration) error { return nil }, + } + _, err := r.Run(context.Background(), RunInput{ + RequestedOperation: "op", + Interactive: true, + Extension: testExtension(srv.URL), + }) + if err == nil { + t.Fatal("want a TerminalError") + } + if strings.Contains(err.Error(), fakeBearer) { + t.Fatalf("bearer token leaked via statusReason: %s", err.Error()) + } + if !strings.Contains(err.Error(), "denied for token") { + t.Errorf("the server's reason must survive redaction; got: %s", err.Error()) + } +} diff --git a/internal/rechallenge/tokencache.go b/internal/rechallenge/tokencache.go new file mode 100644 index 000000000..ff0d175bc --- /dev/null +++ b/internal/rechallenge/tokencache.go @@ -0,0 +1,148 @@ +package rechallenge + +import ( + "errors" + "regexp" + "sync" + "time" + + json "encoding/json/v2" + + "github.com/Automattic/vip/internal/keychain" +) + +// baseServiceName keeps vip-next's elevated tokens isolated from the Node CLI. +const baseServiceName = "vip-next-cli:elevated" + +// nonAlphanumericTC matches the sanitization used by Node's +// API_HOST.replace(/[^a-z0-9]/gi, '-') so production and non-prod hosts get +// distinct service entries. +var nonAlphanumericTC = regexp.MustCompile(`[^a-zA-Z0-9]`) + +// ServiceNameForHost builds the keychain service name for the elevated-token +// cache. Returns the bare base name for the production API host; otherwise +// suffixes ":". +func ServiceNameForHost(apiHost string) string { + if apiHost == keychain.ProductionAPIHost { + return baseServiceName + } + return baseServiceName + ":" + nonAlphanumericTC.ReplaceAllString(apiHost, "-") +} + +// TokenCache stores elevated tokens per scope in a single Go-owned keychain +// entry. The on-disk shape is a JSON blob {scope: ElevatedToken}, keeping +// ClearAll cheap while preserving the Node data shape. +type TokenCache struct { + Keychain *keychain.Keychain + mu sync.Mutex + loaded bool + blob map[string]ElevatedToken +} + +func (c *TokenCache) load() error { + if c.loaded { + return nil + } + raw, err := c.Keychain.Backend.Get(c.Keychain.Service, c.Keychain.Service) + if errors.Is(err, keychain.ErrNotFound) { + c.blob = map[string]ElevatedToken{} + c.loaded = true + return nil + } + if err != nil { + return err + } + parsed := map[string]ElevatedToken{} + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + // Corrupted blob → drop and reset (matches Node). + _ = c.Keychain.Backend.Delete(c.Keychain.Service, c.Keychain.Service) + c.blob = map[string]ElevatedToken{} + c.loaded = true + return nil + } + c.blob = parsed + c.loaded = true + return nil +} + +func (c *TokenCache) write() error { + if len(c.blob) == 0 { + err := c.Keychain.Backend.Delete(c.Keychain.Service, c.Keychain.Service) + if errors.Is(err, keychain.ErrNotFound) { + return nil + } + return err + } + data, err := json.Marshal(c.blob, json.Deterministic(true)) + if err != nil { + return err + } + return c.Keychain.Backend.Set(c.Keychain.Service, c.Keychain.Service, string(data)) +} + +// Get returns the cached token for scope, or nil if missing/expired. +// Expired tokens are evicted as a side effect (matches Node). +func (c *TokenCache) Get(scope string) (*ElevatedToken, error) { + c.mu.Lock() + defer c.mu.Unlock() + if err := c.load(); err != nil { + return nil, err + } + tok, ok := c.blob[scope] + if !ok { + return nil, nil + } + if isExpired(tok) { + delete(c.blob, scope) + if err := c.write(); err != nil { + return nil, err + } + return nil, nil + } + return &tok, nil +} + +func (c *TokenCache) Set(scope string, tok ElevatedToken) error { + c.mu.Lock() + defer c.mu.Unlock() + if err := c.load(); err != nil { + return err + } + c.blob[scope] = tok + return c.write() +} + +func (c *TokenCache) ClearScope(scope string) error { + c.mu.Lock() + defer c.mu.Unlock() + if err := c.load(); err != nil { + return err + } + if _, ok := c.blob[scope]; !ok { + return nil + } + delete(c.blob, scope) + return c.write() +} + +// ClearAll drops the keychain entry entirely. Called on logout. +func (c *TokenCache) ClearAll() error { + c.mu.Lock() + defer c.mu.Unlock() + c.blob = map[string]ElevatedToken{} + c.loaded = true + err := c.Keychain.Backend.Delete(c.Keychain.Service, c.Keychain.Service) + if errors.Is(err, keychain.ErrNotFound) { + return nil + } + return err +} + +// isExpired matches Node's 5-second grace window. A token whose ExpiresAt is +// within the next 5 seconds counts as expired. +func isExpired(tok ElevatedToken) bool { + if tok.ExpiresAt.IsZero() { + return true + } + return time.Now().Add(5 * time.Second).After(tok.ExpiresAt) +} diff --git a/internal/rechallenge/tokencache_test.go b/internal/rechallenge/tokencache_test.go new file mode 100644 index 000000000..a356ec0ba --- /dev/null +++ b/internal/rechallenge/tokencache_test.go @@ -0,0 +1,139 @@ +package rechallenge + +import ( + "testing" + "time" + + "github.com/Automattic/vip/internal/keychain" +) + +type memBackend struct{ store map[string]string } + +func (m *memBackend) Set(s, u, p string) error { + if m.store == nil { + m.store = map[string]string{} + } + m.store[s+"|"+u] = p + return nil +} +func (m *memBackend) Get(s, u string) (string, error) { + if v, ok := m.store[s+"|"+u]; ok { + return v, nil + } + return "", keychain.ErrNotFound +} +func (m *memBackend) Delete(s, u string) error { + if _, ok := m.store[s+"|"+u]; !ok { + return keychain.ErrNotFound + } + delete(m.store, s+"|"+u) + return nil +} + +func newTestCache() *TokenCache { + return &TokenCache{ + Keychain: &keychain.Keychain{Backend: &memBackend{}, Service: "vip-next-cli:elevated"}, + } +} + +func TestTokenCacheRoundTrip(t *testing.T) { + c := newTestCache() + tok := ElevatedToken{Token: "x", ExpiresAt: time.Now().Add(1 * time.Hour), Purpose: "u"} + if err := c.Set("doThing", tok); err != nil { + t.Fatalf("Set: %v", err) + } + got, err := c.Get("doThing") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got == nil || got.Token != "x" { + t.Errorf("Get = %+v, want token x", got) + } +} + +func TestTokenCacheMissingReturnsNil(t *testing.T) { + c := newTestCache() + got, err := c.Get("absent") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != nil { + t.Errorf("Get(absent) = %+v, want nil", got) + } +} + +func TestTokenCacheExpiredEvicted(t *testing.T) { + c := newTestCache() + // Expires within the 5s grace window — counts as expired. + c.Set("doThing", ElevatedToken{Token: "x", ExpiresAt: time.Now().Add(2 * time.Second)}) + got, err := c.Get("doThing") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != nil { + t.Errorf("expired token must be evicted; got %+v", got) + } + // Fresh cache reading the same backend must not see the entry either. + c2 := &TokenCache{Keychain: c.Keychain} + got2, _ := c2.Get("doThing") + if got2 != nil { + t.Errorf("after eviction the keychain blob must not contain doThing; got %+v", got2) + } +} + +func TestTokenCacheClearScope(t *testing.T) { + c := newTestCache() + c.Set("a", ElevatedToken{Token: "a", ExpiresAt: time.Now().Add(time.Hour)}) + c.Set("b", ElevatedToken{Token: "b", ExpiresAt: time.Now().Add(time.Hour)}) + if err := c.ClearScope("a"); err != nil { + t.Fatalf("ClearScope: %v", err) + } + got, _ := c.Get("a") + if got != nil { + t.Errorf("a should be cleared") + } + got, _ = c.Get("b") + if got == nil { + t.Errorf("b should still be present") + } +} + +func TestTokenCacheClearAll(t *testing.T) { + c := newTestCache() + c.Set("a", ElevatedToken{Token: "a", ExpiresAt: time.Now().Add(time.Hour)}) + c.Set("b", ElevatedToken{Token: "b", ExpiresAt: time.Now().Add(time.Hour)}) + if err := c.ClearAll(); err != nil { + t.Fatalf("ClearAll: %v", err) + } + gotA, _ := c.Get("a") + gotB, _ := c.Get("b") + if gotA != nil || gotB != nil { + t.Errorf("ClearAll must drop everything; got a=%+v b=%+v", gotA, gotB) + } + be := c.Keychain.Backend.(*memBackend) + if _, exists := be.store["vip-next-cli:elevated|vip-next-cli:elevated"]; exists { + t.Errorf("keychain entry must be deleted after ClearAll") + } +} + +func TestTokenCacheCorruptedBlobIsReset(t *testing.T) { + c := newTestCache() + be := c.Keychain.Backend.(*memBackend) + _ = be.Set("vip-next-cli:elevated", "vip-next-cli:elevated", "not-json") + got, err := c.Get("anything") + if err != nil { + t.Fatalf("Get on corrupt blob should not error; got %v", err) + } + if got != nil { + t.Errorf("corrupted blob should yield nil; got %+v", got) + } +} + +func TestServiceNameForElevatedTokens(t *testing.T) { + if got := ServiceNameForHost("https://api.wpvip.com"); got != "vip-next-cli:elevated" { + t.Errorf("prod = %q, want vip-next-cli:elevated", got) + } + if got := ServiceNameForHost("https://staging-api.wpvip.com:8443"); got != "vip-next-cli:elevated:https---staging-api-wpvip-com-8443" { + t.Errorf("non-prod = %q", got) + } +} diff --git a/internal/rechallenge/types.go b/internal/rechallenge/types.go new file mode 100644 index 000000000..6bca9e888 --- /dev/null +++ b/internal/rechallenge/types.go @@ -0,0 +1,100 @@ +// Package rechallenge implements the Rechallenge v2 step-up authentication +// flow. Mirrors src/lib/rechallenge/* in the Node implementation. +// +// Contract guarantees (load-bearing per project_rechallenge_v2.md): +// - Parker path templates and the elevated-header name come from +// extensions.rechallenge ON EACH response, never hardcoded. +// - Only mutations are eligible for step-up; queries surface errors unchanged. +// - The elevated-token cache uses a single keychain entry shared with the +// Node binary so logged-in state crosses binaries. +package rechallenge + +import "time" + +const ( + // ElevatedPermissionErrorCode matches src/lib/rechallenge/types.ts. + ElevatedPermissionErrorCode = "elevated-permission-required" + // Version is the rechallenge protocol version this client supports. + Version = "v2" + // ClientType is sent in createSession to identify the caller. + ClientType = "cli" +) + +// Status mirrors RechallengeStatus from types.ts. +type Status string + +const ( + StatusPending Status = "pending" + StatusVerified Status = "verified" + StatusExpired Status = "expired" + StatusFailed Status = "failed" + StatusCancelled Status = "cancelled" +) + +// IsTerminal reports whether the status indicates the flow should stop polling. +func (s Status) IsTerminal() bool { + switch s { + case StatusVerified, StatusExpired, StatusFailed, StatusCancelled: + return true + } + return false +} + +// Extension is the shape of errors[0].extensions.rechallenge from the API. +type Extension struct { + Version string `json:"version"` + CreateSessionPath string `json:"createSessionPath"` + StatusPathTemplate string `json:"statusPathTemplate"` + ExchangePathTemplate string `json:"exchangePathTemplate"` + ElevatedHeaderName string `json:"elevatedHeaderName"` +} + +// IsValid reports whether the extension has all required template fields. +// Mirrors the typeof checks in link.ts:extractElevatedPermission. +func (e Extension) IsValid() bool { + return e.CreateSessionPath != "" && + e.StatusPathTemplate != "" && + e.ExchangePathTemplate != "" && + e.ElevatedHeaderName != "" +} + +// Session is the response from POST {createSessionPath}. +type Session struct { + ChallengeID string `json:"challengeId"` + Status Status `json:"status"` + VerificationURL string `json:"verificationUrl"` + PollIntervalSeconds int `json:"pollIntervalSeconds"` + ExpiresAt time.Time `json:"expiresAt"` +} + +// StatusReason is the optional explanation returned with a terminal status. +type StatusReason struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// SessionStatus is the response from GET {statusPathTemplate}. +type SessionStatus struct { + ChallengeID string `json:"challengeId"` + Status Status `json:"status"` + ExpiresAt time.Time `json:"expiresAt"` + VerifiedAt *time.Time `json:"verifiedAt,omitempty"` + Provider string `json:"provider,omitempty"` + PollIntervalSeconds int `json:"pollIntervalSeconds"` + StatusReason *StatusReason `json:"statusReason,omitempty"` +} + +// ExchangeResponse is the response from POST {exchangePathTemplate}. +type ExchangeResponse struct { + ElevatedToken ElevatedToken `json:"elevatedToken"` +} + +// ElevatedToken is the elevated bearer issued after successful step-up. +// HeaderName is set by the flow orchestrator (copied from Extension.ElevatedHeaderName) +// so the link layer doesn't need to consult the Extension during replay. +type ElevatedToken struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expiresAt"` + Purpose string `json:"purpose"` + HeaderName string `json:"headerName,omitempty"` +} diff --git a/internal/rechallenge/types_test.go b/internal/rechallenge/types_test.go new file mode 100644 index 000000000..09225f329 --- /dev/null +++ b/internal/rechallenge/types_test.go @@ -0,0 +1,56 @@ +package rechallenge + +import ( + "testing" + "time" + + json "encoding/json/v2" +) + +func TestSessionDecode(t *testing.T) { + in := `{"challengeId":"abc","status":"pending","verificationUrl":"https://parker.example/verify/abc","pollIntervalSeconds":2,"expiresAt":"2026-06-05T12:00:00Z"}` + var s Session + if err := json.Unmarshal([]byte(in), &s); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if s.ChallengeID != "abc" { + t.Errorf("ChallengeID = %q, want abc", s.ChallengeID) + } + if s.Status != StatusPending { + t.Errorf("Status = %q, want pending", s.Status) + } + if s.PollIntervalSeconds != 2 { + t.Errorf("PollIntervalSeconds = %d, want 2", s.PollIntervalSeconds) + } + if want := time.Date(2026, 6, 5, 12, 0, 0, 0, time.UTC); !s.ExpiresAt.Equal(want) { + t.Errorf("ExpiresAt = %v, want %v", s.ExpiresAt, want) + } +} + +func TestExtensionDecode(t *testing.T) { + in := `{"version":"v2","createSessionPath":"/p/v2/cli/sessions","statusPathTemplate":"/p/v2/cli/sessions/{challengeId}","exchangePathTemplate":"/p/v2/cli/sessions/{challengeId}/elevated-token","elevatedHeaderName":"x-elevated-token"}` + var e Extension + if err := json.Unmarshal([]byte(in), &e); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if e.Version != Version { + t.Errorf("Version = %q, want %q", e.Version, Version) + } + if e.ElevatedHeaderName != "x-elevated-token" { + t.Errorf("ElevatedHeaderName = %q", e.ElevatedHeaderName) + } +} + +func TestElevatedTokenDecode(t *testing.T) { + in := `{"token":"opaque","expiresAt":"2026-06-05T13:00:00Z","purpose":"updateDefensiveModeStatus","headerName":"x-elevated-token"}` + var tok ElevatedToken + if err := json.Unmarshal([]byte(in), &tok); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if tok.Token != "opaque" { + t.Errorf("Token = %q", tok.Token) + } + if tok.Purpose != "updateDefensiveModeStatus" { + t.Errorf("Purpose = %q", tok.Purpose) + } +} diff --git a/internal/telemetry/config.go b/internal/telemetry/config.go new file mode 100644 index 000000000..870c726bb --- /dev/null +++ b/internal/telemetry/config.go @@ -0,0 +1,34 @@ +package telemetry + +import "strings" + +// Mirrors config/config.publish.json. +const ( + TracksEndpoint = "https://public-api.wordpress.com/rest/v1.1/tracks/record" + TracksUserType = "vip:user_id" + TracksAnonUserType = "anon" + TracksEventPrefix = "vip_cli_" + + // PendoEndpoint is the production Pendo endpoint — API_HOST's default + // (src/lib/api.ts:21, PRODUCTION_API_HOST) plus Pendo.ENDPOINT ("/pendo"). + // Use PendoEndpointFor to honour an overridden API_HOST. + PendoEndpoint = defaultAPIHost + pendoPath + PendoEventPrefix = TracksEventPrefix // same prefix as Tracks per tracker.ts + + defaultAPIHost = "https://api.wpvip.com" + pendoPath = "/pendo" +) + +// PendoEndpointFor builds the Pendo URL for an API host. +// +// Node sends Pendo events through src/lib/api/http.ts, which prefixes API_HOST +// (`process.env.API_HOST || PRODUCTION_API_HOST`), so pointing Node at staging +// points its analytics at staging. Go hardcoded the production URL, which meant +// a developer or CI job running against a local or staging API still emitted +// every event into the production analytics pipeline. +func PendoEndpointFor(apiHost string) string { + if apiHost == "" { + return PendoEndpoint + } + return strings.TrimSuffix(apiHost, "/") + pendoPath +} diff --git a/internal/telemetry/default.go b/internal/telemetry/default.go new file mode 100644 index 000000000..c36aadf75 --- /dev/null +++ b/internal/telemetry/default.go @@ -0,0 +1,67 @@ +package telemetry + +import ( + "os" + "sync" + + "github.com/Automattic/vip/internal/keychain" + "github.com/Automattic/vip/internal/version" +) + +// NewDefault constructs a Tracker wired with Tracks + Pendo clients and the +// keychain-backed UUID store. Returns nil if construction fails — callers +// should check. +// +// When DO_NOT_TRACK / GO_ENV=test / NODE_ENV=test is set the tracker is +// returned in a pre-disabled state without touching the OS keychain, so +// test binaries never block on a Keychain Access prompt. +// +// The UUID lookup is deferred to first event emission via GetUserID so that +// construction never touches the OS keychain. This prevents Keychain Access +// prompts on every invocation (e.g. --version, --help). +func NewDefault() *Tracker { + if isDoNotTrack() { + return &Tracker{Disabled: true} + } + host := os.Getenv("API_HOST") + if host == "" { + host = "https://api.wpvip.com" + } + k := keychain.New(host) + uuidStore := &UUIDStore{Keychain: k} + + // Lazy UUID resolution — do NOT touch keychain at construction. + // First event emission will trigger the lookup (at most once). + var once sync.Once + var cachedUUID string + getUUID := func() string { + once.Do(func() { cachedUUID, _ = uuidStore.Get() }) + return cachedUUID + } + + // Follow the ldflags-injected build version (Makefile -X + // …/internal/version.Version). This was hardcoded to the literal + // "vip-next/dev", so every released build reported itself to Tracks and + // Pendo as a dev build and tagging a release silently changed nothing. + userAgent := "vip-next/" + version.Version + return &Tracker{ + Clients: []Client{ + &TracksClient{ + Endpoint: TracksEndpoint, + GetUserID: getUUID, + UserType: TracksAnonUserType, + UserAgent: userAgent, + }, + &PendoClient{ + // Node prefixes API_HOST (src/lib/api/http.ts); a staging or + // local run must not post into production analytics. + Endpoint: PendoEndpointFor(host), + GetUserID: getUUID, + UserAgent: userAgent, + EventPrefix: TracksEventPrefix, + }, + }, + UUIDStore: uuidStore, + Disabled: false, + } +} diff --git a/internal/telemetry/default_endpoint_test.go b/internal/telemetry/default_endpoint_test.go new file mode 100644 index 000000000..1bb2ebcc3 --- /dev/null +++ b/internal/telemetry/default_endpoint_test.go @@ -0,0 +1,111 @@ +package telemetry + +import ( + "strings" + "testing" + + "github.com/Automattic/vip/internal/version" +) + +// pendoEndpointOf digs the Pendo client out of a constructed Tracker. +func pendoEndpointOf(t *testing.T, tr *Tracker) string { + t.Helper() + if tr == nil { + t.Fatal("NewDefault returned nil") + } + for _, c := range tr.Clients { + if p, ok := c.(*PendoClient); ok { + return p.Endpoint + } + } + t.Fatal("no PendoClient in the default tracker") + return "" +} + +// TestPendoEndpointFollowsAPIHost is a privacy fix, not a cosmetic one. +// +// Node reaches Pendo through src/lib/api/http.ts, which prefixes +// `API_HOST` (src/lib/api.ts:21 — `process.env.API_HOST || PRODUCTION_API_HOST`). +// Point Node at staging and its analytics go to staging. +// +// Go hardcoded https://api.wpvip.com/pendo, so a developer or a CI job running +// against a local or staging API — the exact situation where you generate +// large volumes of junk events, and where the commands under test may be +// exercising a customer's data — still emitted every one of them to the +// PRODUCTION analytics pipeline. NewDefault already reads API_HOST for the +// keychain on the line above, which is what made the divergence easy to miss. +func TestPendoEndpointFollowsAPIHost(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("GO_ENV", "") + t.Setenv("NODE_ENV", "") + t.Setenv("API_HOST", "https://api.staging.wpvip.com") + + got := pendoEndpointOf(t, NewDefault()) + + if strings.Contains(got, "api.wpvip.com") && !strings.Contains(got, "staging") { + t.Errorf("Pendo endpoint = %q; a staging run still ships telemetry to production", got) + } + if got != "https://api.staging.wpvip.com/pendo" { + t.Errorf("Pendo endpoint = %q, want https://api.staging.wpvip.com/pendo", got) + } +} + +// TestPendoEndpointDefaultsToProduction keeps the ordinary case unchanged. +func TestPendoEndpointDefaultsToProduction(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("GO_ENV", "") + t.Setenv("NODE_ENV", "") + t.Setenv("API_HOST", "") + + if got := pendoEndpointOf(t, NewDefault()); got != PendoEndpoint { + t.Errorf("Pendo endpoint = %q, want %q", got, PendoEndpoint) + } +} + +// TestPendoEndpointTolersatesATrailingSlash — API_HOST is user-supplied, and +// "https://api.wpvip.com/" would otherwise produce "…com//pendo". +func TestPendoEndpointTolersatesATrailingSlash(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("GO_ENV", "") + t.Setenv("NODE_ENV", "") + t.Setenv("API_HOST", "https://api.staging.wpvip.com/") + + if got := pendoEndpointOf(t, NewDefault()); got != "https://api.staging.wpvip.com/pendo" { + t.Errorf("Pendo endpoint = %q, want https://api.staging.wpvip.com/pendo", got) + } +} + +// The user agent was hardcoded to the literal "vip-next/dev", so every release +// build reported itself as a dev build to Tracks and Pendo — tagging a release +// would silently not change it. It must follow the ldflags-injected version. +func TestDefaultTrackerUserAgentFollowsBuildVersion(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "") + t.Setenv("GO_ENV", "") + t.Setenv("NODE_ENV", "") + t.Setenv("API_HOST", "https://api.staging.wpvip.com") + + prev := version.Version + t.Cleanup(func() { version.Version = prev }) + version.Version = "5.0.0-beta" + + tr := NewDefault() + want := "vip-next/5.0.0-beta" + + var seen []string + for _, c := range tr.Clients { + switch client := c.(type) { + case *TracksClient: + seen = append(seen, client.UserAgent) + case *PendoClient: + seen = append(seen, client.UserAgent) + } + } + if len(seen) == 0 { + t.Fatal("no clients with a UserAgent; the assertion would be vacuous") + } + for _, got := range seen { + if got != want { + t.Errorf("UserAgent = %q, want %q", got, want) + } + } +} diff --git a/internal/telemetry/pendo.go b/internal/telemetry/pendo.go new file mode 100644 index 000000000..d4ffb94f6 --- /dev/null +++ b/internal/telemetry/pendo.go @@ -0,0 +1,154 @@ +package telemetry + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/Automattic/vip/internal/httpproxy" +) + +// PendoClient posts analytics events to Pendo via the VIP API proxy. +// +// Node parity: mirrors src/lib/analytics/clients/pendo.ts exactly. +// +// The Node client sends to Pendo.ENDPOINT = "/pendo" prefixed by API_HOST +// (https://api.wpvip.com), so the full URL is https://api.wpvip.com/pendo. +// Node attaches a bearer token via the shared http wrapper; Go telemetry +// calls are fire-and-forget without auth (same approach as TracksClient). +// +// Payload shape (mirrors Node's send() method): +// +// { +// "context": { ...env fields, org_id, org_slug, org_sfid, userAgent, userId }, +// "event": "", +// "properties": { ...eventProps }, +// "timestamp": , +// "type": "track", +// "visitorId": "", +// "accountId": "", +// } +type PendoClient struct { + // Endpoint is the full URL, e.g. PendoEndpoint. + Endpoint string + // UserID is the anonymous UUID identifying this visitor. + // If non-empty, used as-is. If empty and GetUserID is non-nil, GetUserID is called lazily. + UserID string + // GetUserID is called lazily on first TrackEvent when UserID is empty. + GetUserID func() string + // UserAgent is the CLI user-agent string. + UserAgent string + // EventPrefix is prepended to event names that don't already carry it. + EventPrefix string + // HTTP is the HTTP client; nil means a default 5-second-timeout client. + HTTP *http.Client +} + +// resolveUserID returns UserID if set, otherwise calls GetUserID(). +// Returns empty string when neither is configured. +func (c *PendoClient) resolveUserID() string { + if c.UserID != "" { + return c.UserID + } + if c.GetUserID != nil { + return c.GetUserID() + } + return "" +} + +// pendoContext mirrors the Node context object merged in trackEvent(). +// Fields use camelCase to match Node's JSON output exactly. +type pendoContext struct { + // Env-derived fields. + UserAgent string `json:"userAgent"` + // Identity fields set per event. + UserID string `json:"userId"` + OrgID any `json:"org_id"` + OrgSlug any `json:"org_slug"` + OrgSfid any `json:"org_sfid"` +} + +// pendoPayload is the JSON body sent to the Pendo endpoint. +// Field names match Node's body construction in send() exactly. +type pendoPayload struct { + Context pendoContext `json:"context"` + Event string `json:"event"` + Properties map[string]any `json:"properties"` + Timestamp int64 `json:"timestamp"` + Type string `json:"type"` + VisitorID string `json:"visitorId"` + AccountID string `json:"accountId"` +} + +// TrackEvent sends a single named event to Pendo. +// The event name is auto-prefixed with EventPrefix if not already present. +// Errors from the HTTP call are swallowed (Node returns false on error). +func (c *PendoClient) TrackEvent(name string, props map[string]any) error { + if !strings.HasPrefix(name, c.EventPrefix) { + name = c.EventPrefix + name + } + + if props == nil { + props = map[string]any{} + } + + userID := c.resolveUserID() + + // Build context — mirrors Node's trackEvent() context merge. + ctx := pendoContext{ + UserAgent: c.UserAgent, + UserID: userID, + OrgID: props["org_slug"], // Node sets org_id = eventProps.org_slug + OrgSlug: props["org_slug"], + OrgSfid: props["org_sfid"], + } + + // accountId = context.org_sfid (Node: `${ this.context.org_sfid as string }`) + accountID := "" + if v, ok := props["org_sfid"]; ok && v != nil { + if s, ok2 := v.(string); ok2 { + accountID = s + } + } + + payload := pendoPayload{ + Context: ctx, + Event: name, + Properties: props, + Timestamp: time.Now().UnixMilli(), + Type: "track", + VisitorID: userID, + AccountID: accountID, + } + + body, err := json.Marshal(payload) + if err != nil { + // Swallow, same as Node's catch block returning false. + return nil + } + + req, err := http.NewRequest("POST", c.Endpoint, bytes.NewReader(body)) + if err != nil { + return nil + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", c.UserAgent) + + httpClient := c.HTTP + if httpClient == nil { + // Node routes Pendo through api/http.ts (analytics/clients/pendo.ts:4), + // so it is proxied by createProxyAgent's policy — not by + // http.DefaultTransport's. See internal/httpproxy. + httpClient = httpproxy.ClientWithTimeout(5 * time.Second) + } + + resp, err := httpClient.Do(req) + if err != nil { + // Node: catch(error) { debug(error); return Promise.resolve(false) } + return nil + } + resp.Body.Close() + return nil +} diff --git a/internal/telemetry/pendo_test.go b/internal/telemetry/pendo_test.go new file mode 100644 index 000000000..7f7abb770 --- /dev/null +++ b/internal/telemetry/pendo_test.go @@ -0,0 +1,233 @@ +package telemetry + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +// captureRequest runs a PendoClient.TrackEvent call against a local httptest +// server and returns the decoded request payload plus the raw HTTP request. +func captureRequest(t *testing.T, c *PendoClient, name string, props map[string]any) (pendoPayload, *http.Request) { + t.Helper() + var captured pendoPayload + var capturedReq *http.Request + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedReq = r + b, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(b, &captured); err != nil { + t.Fatalf("could not decode Pendo payload: %v\nbody: %s", err, b) + } + w.WriteHeader(200) + })) + t.Cleanup(srv.Close) + + c.Endpoint = srv.URL + if err := c.TrackEvent(name, props); err != nil { + t.Fatalf("TrackEvent returned unexpected error: %v", err) + } + if capturedReq == nil { + t.Fatal("no request received by test server") + } + return captured, capturedReq +} + +// TestPendoClientPostsExpectedPayload verifies that TrackEvent sends a POST +// with the correct JSON body matching Node's send() output: event name (prefixed), +// type "track", visitorId, accountId, context fields, and properties. +func TestPendoClientPostsExpectedPayload(t *testing.T) { + c := &PendoClient{ + UserID: "test-anon-uuid-1234", + UserAgent: "vip-cli/test-0.1", + EventPrefix: TracksEventPrefix, + } + props := map[string]any{ + "command": "vip whoami", + "org_slug": "my-org", + "org_sfid": "SF-999", + } + + payload, req := captureRequest(t, c, "whoami_command_execute", props) + + // --- HTTP method and Content-Type --- + if req.Method != "POST" { + t.Errorf("HTTP method = %q, want POST", req.Method) + } + ct := req.Header.Get("Content-Type") + if ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + if req.Header.Get("User-Agent") != "vip-cli/test-0.1" { + t.Errorf("User-Agent = %q, want vip-cli/test-0.1", req.Header.Get("User-Agent")) + } + + // --- type field --- + if payload.Type != "track" { + t.Errorf("type = %q, want track", payload.Type) + } + + // --- event name: should be auto-prefixed --- + wantEvent := "vip_cli_whoami_command_execute" + if payload.Event != wantEvent { + t.Errorf("event = %q, want %q", payload.Event, wantEvent) + } + + // --- visitorId == userId --- + if payload.VisitorID != "test-anon-uuid-1234" { + t.Errorf("visitorId = %q, want test-anon-uuid-1234", payload.VisitorID) + } + + // --- accountId == org_sfid --- + if payload.AccountID != "SF-999" { + t.Errorf("accountId = %q, want SF-999", payload.AccountID) + } + + // --- context.userId == userId --- + if payload.Context.UserID != "test-anon-uuid-1234" { + t.Errorf("context.userId = %q, want test-anon-uuid-1234", payload.Context.UserID) + } + + // --- context.userAgent --- + if payload.Context.UserAgent != "vip-cli/test-0.1" { + t.Errorf("context.userAgent = %q, want vip-cli/test-0.1", payload.Context.UserAgent) + } + + // --- properties are passed through --- + if v, ok := payload.Properties["command"]; !ok || v != "vip whoami" { + t.Errorf("properties[command] = %v, want vip whoami", v) + } + + // --- timestamp is non-zero --- + if payload.Timestamp == 0 { + t.Errorf("timestamp = 0, want non-zero Unix milliseconds") + } +} + +// TestPendoClientCarriesUserIdentity verifies that the anonymous UUID appears +// in both visitorId (top-level) and context.userId, matching Node's behavior +// where visitorId = `${ this.context.userId }` and context.userId = this.userId. +func TestPendoClientCarriesUserIdentity(t *testing.T) { + const anonID = "deadbeef-cafe-babe-0000-111122223333" + c := &PendoClient{ + UserID: anonID, + UserAgent: "vip-cli/test-0.1", + EventPrefix: TracksEventPrefix, + } + + payload, _ := captureRequest(t, c, "some_event", nil) + + if payload.VisitorID != anonID { + t.Errorf("visitorId = %q, want %q", payload.VisitorID, anonID) + } + if payload.Context.UserID != anonID { + t.Errorf("context.userId = %q, want %q", payload.Context.UserID, anonID) + } +} + +// TestPendoClientHonorsExplicitPrefix verifies that a name already carrying +// the prefix is not double-prefixed (mirrors Node: if (!eventName.startsWith(this.eventPrefix))). +func TestPendoClientHonorsExplicitPrefix(t *testing.T) { + c := &PendoClient{ + UserID: "u", + UserAgent: "x", + EventPrefix: TracksEventPrefix, + } + + payload, _ := captureRequest(t, c, "vip_cli_already_prefixed", nil) + + if payload.Event != "vip_cli_already_prefixed" { + t.Errorf("event = %q (must not double-prefix)", payload.Event) + } +} + +// TestPendoClientOrgContextFields verifies that org_id, org_slug, and org_sfid +// from eventProps are copied into the context (Node: this.context.org_id = eventProps.org_slug). +func TestPendoClientOrgContextFields(t *testing.T) { + c := &PendoClient{ + UserID: "u", + UserAgent: "x", + EventPrefix: TracksEventPrefix, + } + props := map[string]any{ + "org_slug": "acme", + "org_sfid": "SF-001", + } + + payload, _ := captureRequest(t, c, "test_event", props) + + // org_id and org_slug should both equal eventProps.org_slug (Node behavior). + if payload.Context.OrgID != "acme" { + t.Errorf("context.org_id = %v, want acme", payload.Context.OrgID) + } + if payload.Context.OrgSlug != "acme" { + t.Errorf("context.org_slug = %v, want acme", payload.Context.OrgSlug) + } + if payload.Context.OrgSfid != "SF-001" { + t.Errorf("context.org_sfid = %v, want SF-001", payload.Context.OrgSfid) + } + if payload.AccountID != "SF-001" { + t.Errorf("accountId = %q, want SF-001", payload.AccountID) + } +} + +// TestPendoClientSwallowsNetworkError verifies that a network failure returns +// nil (not an error), mirroring Node's catch block returning Promise.resolve(false). +func TestPendoClientSwallowsNetworkError(t *testing.T) { + c := &PendoClient{ + Endpoint: "http://127.0.0.1:1", // nothing listening + UserID: "u", + UserAgent: "x", + EventPrefix: TracksEventPrefix, + } + if err := c.TrackEvent("test", nil); err != nil { + t.Errorf("expected nil on network error (Node swallows errors), got %v", err) + } +} + +// TestPendoClientLazyUserIDResolved verifies that GetUserID is not called at +// construction time — only at TrackEvent call time. +func TestPendoClientLazyUserIDResolved(t *testing.T) { + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer srv.Close() + + c := &PendoClient{ + Endpoint: srv.URL, + GetUserID: func() string { calls++; return "lazy-uuid" }, + UserAgent: "vip-next/test", + EventPrefix: TracksEventPrefix, + } + // Before TrackEvent: GetUserID must not be called. + if calls != 0 { + t.Errorf("GetUserID called %d times before TrackEvent; want 0", calls) + } + if err := c.TrackEvent("test", nil); err != nil { + t.Fatalf("TrackEvent: %v", err) + } + if calls != 1 { + t.Errorf("GetUserID called %d times after TrackEvent; want 1", calls) + } +} + +// TestPendoClientPrefersExplicitUserID verifies that GetUserID is never called +// when UserID is already set explicitly. +func TestPendoClientPrefersExplicitUserID(t *testing.T) { + c := &PendoClient{ + UserID: "explicit", + GetUserID: func() string { t.Error("GetUserID must not be called when UserID is set"); return "" }, + UserAgent: "x", + EventPrefix: TracksEventPrefix, + } + payload, _ := captureRequest(t, c, "test_event", nil) + if payload.VisitorID != "explicit" { + t.Errorf("visitorId = %q, want explicit", payload.VisitorID) + } + if payload.Context.UserID != "explicit" { + t.Errorf("context.userId = %q, want explicit", payload.Context.UserID) + } +} diff --git a/internal/telemetry/scrub.go b/internal/telemetry/scrub.go new file mode 100644 index 000000000..5e18bb56c --- /dev/null +++ b/internal/telemetry/scrub.go @@ -0,0 +1,90 @@ +package telemetry + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/Automattic/vip/internal/redact" +) + +// userPathRE matches the home-directory roots whose next path segment is a +// username. Anchored on the platform conventions rather than on "any absolute +// path", because /usr/local/bin and /etc/hosts are not sensitive and stripping +// them would gut the payload. +var userPathRE = regexp.MustCompile(`(?i)(/Users/|/home/|[A-Z]:\\Users\\)([^/\\:;,'"` + "`" + `\s]+)`) + +// ScrubErrorText removes personally identifying and credential material from +// the text of an error before it is attached to a telemetry event. +// +// This exists because cmd/vip-next/main.go registers a cli_error hook that +// posts err.Error() to public-api.wordpress.com. That hook is Go-only — the +// Node CLI has no equivalent and never sends error text anywhere — so every +// byte it carries is surface the rewrite added. vip-next errors routinely +// interpolate absolute paths (import sql, import media, dev-env, and every +// wrapped os.Open failure), and an absolute path carries the account name, +// which is often the user's real name, and the directory tree, which is often a +// client's name. +// +// Removed, in order: +// +// 1. credentials, via internal/redact — presigned query strings, URL userinfo, +// JWTs, Bearer tokens; +// 2. this process's working directory, temp directory and home directory, +// longest match first so a cwd nested inside home does not decay to +// "$HOME/clients/acme-corp"; +// 3. any remaining /Users/, /home/ or C:\Users\, which is +// the net for paths that came from a config file, instance data or a server +// response rather than from this process. +// +// Kept: everything else. A scrubbed message still names the operation, the +// filename, the host and the failure, which is the whole justification for +// scrubbing rather than dropping the hook. +func ScrubErrorText(s string) string { + s = redact.Text(s) + for _, r := range scrubRoots() { + s = strings.ReplaceAll(s, r.path, r.placeholder) + } + return userPathRE.ReplaceAllString(s, "$1") +} + +type scrubRoot struct { + path string + placeholder string +} + +// scrubRoots returns the directories to anonymise, longest first. +// +// Each root is offered in both its literal and symlink-resolved form: on macOS +// os.TempDir() reports /var/folders/... while anything that actually opened a +// file there reports /private/var/folders/..., and the two must both go. +func scrubRoots() []scrubRoot { + var roots []scrubRoot + add := func(path, placeholder string) { + path = strings.TrimSuffix(filepath.Clean(path), string(filepath.Separator)) + if path == "" || path == string(filepath.Separator) { + return + } + roots = append(roots, scrubRoot{path: path, placeholder: placeholder}) + if resolved, err := filepath.EvalSymlinks(path); err == nil && resolved != path { + roots = append(roots, scrubRoot{path: resolved, placeholder: placeholder}) + } + } + + if cwd, err := os.Getwd(); err == nil { + add(cwd, "") + } + add(os.TempDir(), "") + if home, err := os.UserHomeDir(); err == nil { + add(home, "") + } + + // Longest first: the working directory is usually inside the home + // directory, and replacing home first would leave the project path visible. + sort.SliceStable(roots, func(i, j int) bool { + return len(roots[i].path) > len(roots[j].path) + }) + return roots +} diff --git a/internal/telemetry/scrub_test.go b/internal/telemetry/scrub_test.go new file mode 100644 index 000000000..e4cffc49b --- /dev/null +++ b/internal/telemetry/scrub_test.go @@ -0,0 +1,131 @@ +package telemetry + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// TestScrubErrorTextRemovesTheHomeDirectory is the finding that motivated this +// function: the cli_error hook ships err.Error() verbatim to +// public-api.wordpress.com, and vip-next errors interpolate absolute paths +// constantly (import sql, import media, dev-env, every os.Open failure). The +// home directory contains the user's account name, which is PII on its own and +// frequently their real name. +func TestScrubErrorTextRemovesTheHomeDirectory(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("no home directory available: %v", err) + } + path := filepath.Join(home, "clients", "acme-corp", "db-dump.sql") + + got := ScrubErrorText("open " + path + ": permission denied") + + if strings.Contains(got, home) { + t.Errorf("home directory survived:\n\t%s", got) + } + if !strings.Contains(got, "permission denied") { + t.Errorf("the actual failure was lost:\n\t%s", got) + } +} + +// TestScrubErrorTextRemovesTheWorkingDirectory pins the ordering. The working +// directory is usually INSIDE the home directory, so a scrubber that replaced +// home first would emit "$HOME/clients/acme-corp/...", still naming the client. +// Longest prefix must win. +func TestScrubErrorTextRemovesTheWorkingDirectory(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Skipf("no working directory available: %v", err) + } + + got := ScrubErrorText("could not read " + filepath.Join(cwd, "wp-config.php")) + + if strings.Contains(got, cwd) { + t.Errorf("working directory survived:\n\t%s", got) + } + if !strings.Contains(got, "wp-config.php") { + t.Errorf("the filename was lost; it is the diagnostic part:\n\t%s", got) + } +} + +func TestScrubErrorTextRemovesTheTempDirectory(t *testing.T) { + tmp := os.TempDir() + + got := ScrubErrorText("staging file " + filepath.Join(tmp, "vip-import-9271", "chunk.0") + " vanished") + + if strings.Contains(got, tmp) { + t.Errorf("temp directory survived:\n\t%s", got) + } +} + +// TestScrubErrorTextRemovesForeignHomePaths is the safety net. Not every +// absolute path in an error came from THIS process's home: paths are read out +// of config files, instance data, SQL dumps and server responses. The username +// is the sensitive part, so it goes regardless of which root it hangs off. +func TestScrubErrorTextRemovesForeignHomePaths(t *testing.T) { + cases := []string{ + "/Users/jsmith/Sites/client/wp-content", + "/home/jsmith/sites/client/wp-content", + } + if runtime.GOOS == "windows" { + cases = append(cases, `C:\Users\jsmith\Sites\client`) + } + for _, path := range cases { + got := ScrubErrorText("no such file: " + path) + if strings.Contains(got, "jsmith") { + t.Errorf("username survived in %q:\n\t%s", path, got) + } + if !strings.Contains(got, "no such file") { + t.Errorf("message body lost for %q:\n\t%s", path, got) + } + } +} + +// TestScrubErrorTextAlsoRemovesCredentials confirms the path scrubbing is +// layered on top of internal/redact rather than replacing it. An earlier slice +// found proxy errors carrying socks5://user:pass@host into this exact hook and +// redacted them at the source; this is the second line of defence, for the +// sources nobody has audited yet. +func TestScrubErrorTextAlsoRemovesCredentials(t *testing.T) { + got := ScrubErrorText(`Get "https://vip.s3.amazonaws.com/export.sql?X-Amz-Signature=abc123def456": timeout`) + if strings.Contains(got, "X-Amz-Signature") || strings.Contains(got, "abc123def456") { + t.Errorf("presigned credential survived:\n\t%s", got) + } + + got = ScrubErrorText("proxy socks5://alice:hunter2@corp.example:1080 refused") + if strings.Contains(got, "hunter2") { + t.Errorf("proxy password survived:\n\t%s", got) + } +} + +// TestScrubErrorTextKeepsOrdinaryMessagesIntact is the counterweight: the whole +// point of scrubbing rather than dropping the hook is that the payload stays +// useful. If this test starts failing, the scrubber has become too aggressive +// and removing the hook (Node has none) is the better trade. +func TestScrubErrorTextKeepsOrdinaryMessagesIntact(t *testing.T) { + for _, msg := range []string{ + "appctx: GraphQL client not configured", + "environment my-site is not running; run `vip dev-env start`", + "failed to reach public-api.wordpress.com: connection refused", + "import sql: file is not a valid SQL export", + } { + if got := ScrubErrorText(msg); got != msg { + t.Errorf("scrubber rewrote a message with nothing sensitive in it:\n\tin: %s\n\tout: %s", msg, got) + } + } +} + +func TestScrubErrorTextIsIdempotent(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("no home directory available: %v", err) + } + in := "open " + filepath.Join(home, "a", "b.sql") + ": denied" + once := ScrubErrorText(in) + if twice := ScrubErrorText(once); twice != once { + t.Errorf("not idempotent:\n\t1x: %s\n\t2x: %s", once, twice) + } +} diff --git a/internal/telemetry/tracker.go b/internal/telemetry/tracker.go new file mode 100644 index 000000000..fefccde74 --- /dev/null +++ b/internal/telemetry/tracker.go @@ -0,0 +1,76 @@ +package telemetry + +import ( + "fmt" + "os" +) + +// Client is the common interface satisfied by TracksClient and PendoClient. +type Client interface { + TrackEvent(name string, props map[string]any) error +} + +// Tracker fans out analytics events to all configured Clients. +// Set Disabled to suppress all events without removing the clients. +// isDoNotTrack() also suppresses events when the environment signals opt-out. +type Tracker struct { + Clients []Client + UUIDStore *UUIDStore + Disabled bool +} + +// TrackEvent emits name with props to every configured Client. +// It is a no-op when the tracker is disabled or DO_NOT_TRACK / test env vars are set. +func (t *Tracker) TrackEvent(name string, props map[string]any) { + if t.Disabled || isDoNotTrack() { + return + } + for _, c := range t.Clients { + _ = c.TrackEvent(name, props) + } +} + +// AliasUser emits a special "_alias_user" event that links the anonymous UUID +// to the authenticated VIP user ID, then updates the UUID store so subsequent +// events carry the user's real identity — mirroring aliasUser() in tracker.ts. +func (t *Tracker) AliasUser(vipUserID int64) { + if vipUserID == 0 || t.Disabled || isDoNotTrack() { + return + } + prevID := "" + if t.UUIDStore != nil { + prevID, _ = t.UUIDStore.Get() + } + t.TrackEvent("_alias_user", map[string]any{ + "_ui": vipUserID, + "_ut": TracksUserType, + "anonid": prevID, + }) + if t.UUIDStore != nil { + _ = t.UUIDStore.Set(fmt.Sprintf("%d", vipUserID)) + } +} + +// MakeCommandTracker returns a closure that emits "_command_" +// events, merging baseInfo with any per-call data — mirroring makeCommandTracker() +// in tracker.ts. +func (t *Tracker) MakeCommandTracker(command string, info map[string]any) func(string, map[string]any) { + return func(eventType string, data map[string]any) { + merged := make(map[string]any, len(info)+len(data)) + for k, v := range info { + merged[k] = v + } + for k, v := range data { + merged[k] = v + } + t.TrackEvent(fmt.Sprintf("%s_command_%s", command, eventType), merged) + } +} + +// isDoNotTrack returns true when any of the standard opt-out environment +// variables are set, matching the Node binary's behaviour. +func isDoNotTrack() bool { + return os.Getenv("DO_NOT_TRACK") != "" || + os.Getenv("GO_ENV") == "test" || + os.Getenv("NODE_ENV") == "test" +} diff --git a/internal/telemetry/tracker_test.go b/internal/telemetry/tracker_test.go new file mode 100644 index 000000000..3b380c414 --- /dev/null +++ b/internal/telemetry/tracker_test.go @@ -0,0 +1,76 @@ +package telemetry + +import ( + "sync" + "testing" +) + +type fakeClient struct { + mu sync.Mutex + events []string + props []map[string]any +} + +func (f *fakeClient) TrackEvent(name string, props map[string]any) error { + f.mu.Lock() + defer f.mu.Unlock() + f.events = append(f.events, name) + f.props = append(f.props, props) + return nil +} + +func TestTrackerFanOut(t *testing.T) { + a, b := &fakeClient{}, &fakeClient{} + tr := &Tracker{Clients: []Client{a, b}} + tr.TrackEvent("foo", map[string]any{"x": 1}) + if len(a.events) != 1 || len(b.events) != 1 { + t.Errorf("expected fan-out; a=%d b=%d", len(a.events), len(b.events)) + } +} + +func TestTrackerDoNotTrackDisables(t *testing.T) { + c := &fakeClient{} + tr := &Tracker{Clients: []Client{c}, Disabled: true} + tr.TrackEvent("foo", nil) + if len(c.events) != 0 { + t.Error("expected no events when Disabled") + } +} + +func TestMakeCommandTracker(t *testing.T) { + c := &fakeClient{} + tr := &Tracker{Clients: []Client{c}} + ct := tr.MakeCommandTracker("whoami", map[string]any{"command": "vip whoami"}) + ct("execute", nil) + ct("success", map[string]any{"duration_ms": 42}) + if len(c.events) != 2 { + t.Fatalf("expected 2 events, got %d", len(c.events)) + } + if c.events[0] != "whoami_command_execute" || c.events[1] != "whoami_command_success" { + t.Errorf("event names = %v", c.events) + } + if c.props[1]["command"] != "vip whoami" || c.props[1]["duration_ms"] != 42 { + t.Errorf("merged props = %v", c.props[1]) + } +} + +func TestAliasUserEmitsAliasEvent(t *testing.T) { + c := &fakeClient{} + store := newTestUUIDStore() + store.Set("anon-id") + tr := &Tracker{Clients: []Client{c}, UUIDStore: store} + tr.AliasUser(99) + if len(c.events) != 1 || c.events[0] != "_alias_user" { + t.Errorf("expected _alias_user event, got %v", c.events) + } + if c.props[0]["_ui"] != int64(99) { + t.Errorf("_ui = %v, want 99", c.props[0]["_ui"]) + } + if c.props[0]["anonid"] != "anon-id" { + t.Errorf("anonid = %v, want anon-id", c.props[0]["anonid"]) + } + got, _ := store.Get() + if got != "99" { + t.Errorf("UUID after alias = %q, want %q", got, "99") + } +} diff --git a/internal/telemetry/tracks.go b/internal/telemetry/tracks.go new file mode 100644 index 000000000..efa0555ad --- /dev/null +++ b/internal/telemetry/tracks.go @@ -0,0 +1,82 @@ +package telemetry + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Automattic/vip/internal/httpproxy" +) + +// TracksClient posts analytics events to Automattic Tracks. +// +// Node parity: field names and prefix logic match src/lib/analytics/clients/tracks.ts. +// +// Known gap: Node's trackEvent sets `is_vip` on every event via checkIfUserIsVip(), +// which performs a per-event GraphQL call. That per-event network call is too expensive +// to replicate here; is_vip is intentionally omitted until a cached approach is designed. +// +// Addition vs Node: events[0][cli_binary_kind]=go-native is injected on every event +// per spec §9.3 for rollout adoption tracking. +type TracksClient struct { + Endpoint string + UserID string // if non-empty, used as-is + GetUserID func() string // called lazily on first TrackEvent when UserID is empty + UserType string + UserAgent string + HTTP *http.Client +} + +// resolveUserID returns UserID if set, otherwise calls GetUserID(). +// Returns empty string when neither is configured. +func (c *TracksClient) resolveUserID() string { + if c.UserID != "" { + return c.UserID + } + if c.GetUserID != nil { + return c.GetUserID() + } + return "" +} + +// TrackEvent sends a single named event to Tracks. +// The event name is auto-prefixed with TracksEventPrefix ("vip_cli_") if not already present. +func (c *TracksClient) TrackEvent(name string, props map[string]any) error { + if !strings.HasPrefix(name, TracksEventPrefix) { + name = TracksEventPrefix + name + } + + form := url.Values{} + form.Set("commonProps[_ui]", c.resolveUserID()) + form.Set("commonProps[_ut]", c.UserType) + form.Set("commonProps[_via_ua]", c.UserAgent) + form.Set("events[0][_en]", name) + // Spec §9.3: rollout adoption tracking — not present in Node binary. + form.Set("events[0][cli_binary_kind]", "go-native") + for k, v := range props { + form.Set(fmt.Sprintf("events[0][%s]", k), fmt.Sprint(v)) + } + + req, err := http.NewRequest("POST", c.Endpoint, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", c.UserAgent) + + httpClient := c.HTTP + if httpClient == nil { + // A bare &http.Client{} inherits http.DefaultTransport's proxy policy, + // which is the inverse of Node's. See internal/httpproxy. + httpClient = httpproxy.ClientWithTimeout(5 * time.Second) + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + resp.Body.Close() + return nil +} diff --git a/internal/telemetry/tracks_test.go b/internal/telemetry/tracks_test.go new file mode 100644 index 000000000..efc37fe6e --- /dev/null +++ b/internal/telemetry/tracks_test.go @@ -0,0 +1,129 @@ +package telemetry + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestTracksClientPostsExpectedForm(t *testing.T) { + var body string + var ua string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ua = r.Header.Get("User-Agent") + b, _ := io.ReadAll(r.Body) + body = string(b) + w.WriteHeader(200) + })) + defer srv.Close() + c := &TracksClient{ + Endpoint: srv.URL, + UserID: "anon-uuid", + UserType: TracksAnonUserType, + UserAgent: "vip-next/test1.0", + } + if err := c.TrackEvent("whoami_command_execute", map[string]any{"command": "vip whoami"}); err != nil { + t.Fatalf("TrackEvent: %v", err) + } + if ua != "vip-next/test1.0" { + t.Errorf("User-Agent = %q, want vip-next/test1.0", ua) + } + v, err := url.ParseQuery(body) + if err != nil { + t.Fatalf("body not form-encoded: %v", err) + } + if v.Get("events[0][_en]") != "vip_cli_whoami_command_execute" { + t.Errorf("event name = %q, want vip_cli_whoami_command_execute", v.Get("events[0][_en]")) + } + if v.Get("events[0][command]") != "vip whoami" { + t.Errorf("event prop = %q", v.Get("events[0][command]")) + } + if v.Get("commonProps[_ui]") != "anon-uuid" { + t.Errorf("commonProps[_ui] = %q", v.Get("commonProps[_ui]")) + } + if v.Get("commonProps[_ut]") != "anon" { + t.Errorf("commonProps[_ut] = %q", v.Get("commonProps[_ut]")) + } +} + +func TestTracksClientHonorsExplicitPrefix(t *testing.T) { + var body string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + body = string(b) + })) + defer srv.Close() + c := &TracksClient{Endpoint: srv.URL, UserID: "u", UserType: "anon", UserAgent: "x"} + c.TrackEvent("vip_cli_already_prefixed", nil) + v, _ := url.ParseQuery(body) + if v.Get("events[0][_en]") != "vip_cli_already_prefixed" { + t.Errorf("event name = %q (must not double-prefix)", v.Get("events[0][_en]")) + } +} + +func TestTracksClientSendsBinaryKind(t *testing.T) { + var body string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + body = string(b) + })) + defer srv.Close() + c := &TracksClient{Endpoint: srv.URL, UserID: "u", UserType: "anon", UserAgent: "x"} + c.TrackEvent("test", nil) + v, _ := url.ParseQuery(body) + if v.Get("events[0][cli_binary_kind]") != "go-native" { + t.Errorf("expected cli_binary_kind=go-native; body=%s", body) + } + if !strings.Contains(body, "cli_binary_kind") { + t.Errorf("body missing cli_binary_kind: %s", body) + } +} + +func TestTracksClientLazyUserIDResolved(t *testing.T) { + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer srv.Close() + + c := &TracksClient{ + Endpoint: srv.URL, + GetUserID: func() string { calls++; return "lazy-uuid" }, + UserType: "anon", + UserAgent: "vip-next/test", + } + // Before TrackEvent: GetUserID must not be called. + if calls != 0 { + t.Errorf("GetUserID called %d times before TrackEvent; want 0", calls) + } + c.TrackEvent("test", nil) + if calls != 1 { + t.Errorf("GetUserID called %d times after TrackEvent; want 1", calls) + } +} + +func TestTracksClientPrefersExplicitUserID(t *testing.T) { + var body string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + body = string(b) + w.WriteHeader(200) + })) + defer srv.Close() + + c := &TracksClient{ + Endpoint: srv.URL, + UserID: "explicit", + GetUserID: func() string { t.Error("GetUserID must not be called when UserID is set"); return "" }, + UserType: "anon", + UserAgent: "x", + } + c.TrackEvent("test", nil) + v, _ := url.ParseQuery(body) + if v.Get("commonProps[_ui]") != "explicit" { + t.Errorf("commonProps[_ui] = %q, want explicit", v.Get("commonProps[_ui]")) + } +} diff --git a/internal/telemetry/uuid.go b/internal/telemetry/uuid.go new file mode 100644 index 000000000..a11954f45 --- /dev/null +++ b/internal/telemetry/uuid.go @@ -0,0 +1,50 @@ +// Package telemetry handles analytics (Tracks + Pendo) for the Go binary. +// Anonymous UUIDs use vip-next's private keychain namespace so writes cannot +// alter the Node CLI's telemetry identity. +package telemetry + +import ( + "crypto/rand" + "encoding/hex" + "errors" + + "github.com/Automattic/vip/internal/keychain" +) + +type UUIDStore struct { + Keychain *keychain.Keychain +} + +func (s *UUIDStore) serviceName() string { return s.Keychain.Service + "-uuid" } + +func (s *UUIDStore) Get() (string, error) { + svc := s.serviceName() + v, err := s.Keychain.Backend.Get(svc, svc) + if err == nil { + return v, nil + } + if !errors.Is(err, keychain.ErrNotFound) { + return "", err + } + id, err := newRandomUUID() + if err != nil { + return "", err + } + if err := s.Keychain.Backend.Set(svc, svc, id); err != nil { + return "", err + } + return id, nil +} + +func (s *UUIDStore) Set(id string) error { + svc := s.serviceName() + return s.Keychain.Backend.Set(svc, svc, id) +} + +func newRandomUUID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/internal/telemetry/uuid_test.go b/internal/telemetry/uuid_test.go new file mode 100644 index 000000000..9531da836 --- /dev/null +++ b/internal/telemetry/uuid_test.go @@ -0,0 +1,90 @@ +package telemetry + +import ( + "errors" + "testing" + + "github.com/Automattic/vip/internal/keychain" +) + +type memBackend struct{ store map[string]string } + +func (m *memBackend) Set(s, u, p string) error { + if m.store == nil { + m.store = map[string]string{} + } + m.store[s+"|"+u] = p + return nil +} +func (m *memBackend) Get(s, u string) (string, error) { + if v, ok := m.store[s+"|"+u]; ok { + return v, nil + } + return "", keychain.ErrNotFound +} +func (m *memBackend) Delete(s, u string) error { + delete(m.store, s+"|"+u) + return nil +} + +func newTestUUIDStore() *UUIDStore { + return &UUIDStore{ + Keychain: &keychain.Keychain{Backend: &memBackend{}, Service: "vip-next-cli"}, + } +} + +func TestGetUUIDGeneratesAndPersistsWhenMissing(t *testing.T) { + s := newTestUUIDStore() + id1, err := s.Get() + if err != nil { + t.Fatalf("Get: %v", err) + } + if id1 == "" { + t.Error("generated UUID is empty") + } + id2, _ := s.Get() + if id1 != id2 { + t.Errorf("second Get returned different UUID: %q vs %q", id1, id2) + } +} + +func TestSetUUIDPersistsExplicitValue(t *testing.T) { + s := newTestUUIDStore() + if err := s.Set("explicit-id-42"); err != nil { + t.Fatalf("Set: %v", err) + } + got, err := s.Get() + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != "explicit-id-42" { + t.Errorf("Get = %q, want %q", got, "explicit-id-42") + } +} + +func TestUUIDStoreUsesUUIDServiceSuffix(t *testing.T) { + s := newTestUUIDStore() + s.Set("test-id") + be := s.Keychain.Backend.(*memBackend) + if _, ok := be.store["vip-next-cli-uuid|vip-next-cli-uuid"]; !ok { + t.Errorf("expected key vip-next-cli-uuid|vip-next-cli-uuid in store; got %v", be.store) + } +} + +func TestUUIDStoreReturnsErrOnBackendError(t *testing.T) { + be := &errBackend{} + s := &UUIDStore{Keychain: &keychain.Keychain{Backend: be, Service: "vip-next-cli"}} + _, err := s.Get() + if err == nil { + t.Error("expected error when backend fails") + } + if errors.Is(err, keychain.ErrNotFound) { + t.Error("non-NotFound errors must surface as-is") + } +} + +type errBackend struct{} + +func (errBackend) Set(string, string, string) error { return errors.New("boom") } +func (errBackend) Get(string, string) (string, error) { return "", errors.New("boom") } +func (errBackend) Delete(string, string) error { return errors.New("boom") } From 650460d5d92c4248bd5264a5af63d8f615dea4f6 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 20 Aug 2026 18:36:31 -0500 Subject: [PATCH 08/32] feat(go): app and environment context resolution Ported from vip-cli-golang@72ef2f89 (feature/go-rewrite). --- internal/appctx/app_resolver.go | 202 ++++++++++++++++++++++ internal/appctx/app_resolver_test.go | 187 +++++++++++++++++++++ internal/appctx/confirm.go | 180 ++++++++++++++++++++ internal/appctx/confirm_payload_test.go | 201 ++++++++++++++++++++++ internal/appctx/confirm_test.go | 120 +++++++++++++ internal/appctx/context.go | 94 +++++++++++ internal/appctx/context_test.go | 56 ++++++ internal/appctx/env_resolver.go | 110 ++++++++++++ internal/appctx/env_resolver_test.go | 215 ++++++++++++++++++++++++ internal/appctx/format.go | 72 ++++++++ internal/appctx/format_test.go | 75 +++++++++ internal/appctx/interactive.go | 50 ++++++ internal/appctx/interactive_test.go | 134 +++++++++++++++ internal/appctx/middleware.go | 56 ++++++ internal/appctx/middleware_test.go | 88 ++++++++++ internal/appctx/prompts.go | 70 ++++++++ internal/appctx/prompts_test.go | 97 +++++++++++ internal/appctx/required_args.go | 24 +++ internal/appctx/required_args_test.go | 38 +++++ internal/appctx/telemetry.go | 22 +++ internal/appctx/telemetry_test.go | 42 +++++ internal/appctx/wildcard.go | 29 ++++ internal/appctx/wildcard_test.go | 53 ++++++ 23 files changed, 2215 insertions(+) create mode 100644 internal/appctx/app_resolver.go create mode 100644 internal/appctx/app_resolver_test.go create mode 100644 internal/appctx/confirm.go create mode 100644 internal/appctx/confirm_payload_test.go create mode 100644 internal/appctx/confirm_test.go create mode 100644 internal/appctx/context.go create mode 100644 internal/appctx/context_test.go create mode 100644 internal/appctx/env_resolver.go create mode 100644 internal/appctx/env_resolver_test.go create mode 100644 internal/appctx/format.go create mode 100644 internal/appctx/format_test.go create mode 100644 internal/appctx/interactive.go create mode 100644 internal/appctx/interactive_test.go create mode 100644 internal/appctx/middleware.go create mode 100644 internal/appctx/middleware_test.go create mode 100644 internal/appctx/prompts.go create mode 100644 internal/appctx/prompts_test.go create mode 100644 internal/appctx/required_args.go create mode 100644 internal/appctx/required_args_test.go create mode 100644 internal/appctx/telemetry.go create mode 100644 internal/appctx/telemetry_test.go create mode 100644 internal/appctx/wildcard.go create mode 100644 internal/appctx/wildcard_test.go diff --git a/internal/appctx/app_resolver.go b/internal/appctx/app_resolver.go new file mode 100644 index 000000000..6f5d7a072 --- /dev/null +++ b/internal/appctx/app_resolver.go @@ -0,0 +1,202 @@ +package appctx + +import ( + "fmt" + "strconv" + "strings" + + "github.com/Khan/genqlient/graphql" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/gql" +) + +// AppContextConfig wires the WithAppContext middleware to a GraphQL client. +type AppContextConfig struct { + Client graphql.Client +} + +// WithAppContext returns a middleware that resolves the --app flag (or the +// @app alias propagated into it by envalias.Rewrite) to an App and stashes +// it in cmd.Context() via WithAppEnv. +// +// Behavior: +// - --app numeric -> ResolveAppByID +// - --app non-numeric -> ResolveAppByName (first match) +// - --app empty, NI -> error +// - --app empty, interactive -> prompt for name, then resolve +// - no match -> error mentioning the lookup key +// +// The full app.environments list is stashed via AppEnv.SetAvailableEnvs so +// WithEnvContext (Task 9) can narrow without another network roundtrip. +func WithAppContext(cfg AppContextConfig) Middleware { + return func(next RunFunc) RunFunc { + return func(cmd *cobra.Command, args []string) error { + appFlag := lookupFlag(cmd, "app") + if appFlag == "" { + prompted, err := Input(cmd, "App name or ID:", "") + if err != nil { + return fmt.Errorf("--app is required: %w", err) + } + appFlag = strings.TrimSpace(prompted) + if appFlag == "" { + return fmt.Errorf("--app is required") + } + } + + envFlag := lookupFlag(cmd, "env") + + app, envs, err := resolveApp(cmd, cfg.Client, appFlag, envFlag) + if err != nil { + return err + } + + ae := FromContext(cmd.Context()) + if ae == nil { + ae = &AppEnv{} + } + ae.App = app + ae.SetAvailableEnvs(envs) + cmd.SetContext(WithAppEnv(cmd.Context(), ae)) + return next(cmd, args) + } + } +} + +// envGetter unifies the differently-named env structs that ResolveAppByID +// and ResolveAppByName produce. genqlient emits a GetXxx() method per field +// on every node type, so both `*ResolveAppByIDAppEnvironmentsAppEnvironment` +// and `*ResolveAppByNameAppsAppListEdgesAppEnvironmentsAppEnvironment` +// satisfy this interface — no reflection needed. +type envGetter interface { + GetId() *int64 + GetAppId() *int64 + GetName() *string + GetType() *string + GetUniqueLabel() *string + GetDefaultDomain() *string + GetIsMultisite() *bool +} + +func resolveApp(cmd *cobra.Command, client graphql.Client, appKey, envKey string) (App, []Env, error) { + if client == nil { + return App{}, nil, fmt.Errorf("appctx: GraphQL client not configured") + } + // envKey is intentionally NOT passed to the GraphQL query: the + // environments field has no useful filter (server-side filter would only + // match env.name, but Node's getEnvIdentifier resolves on env.type for the + // main env). Fetch all envs and filter client-side in WithEnvContext. + _ = envKey + ctx := cmd.Context() + + if id, err := strconv.ParseInt(appKey, 10, 64); err == nil { + resp, qerr := gql.ResolveAppByID(ctx, client, id) + if qerr != nil { + return App{}, nil, fmt.Errorf("resolve app id=%d: %w", id, qerr) + } + if resp == nil || resp.App == nil || resp.App.Id == nil { + return App{}, nil, fmt.Errorf("no app matching id=%d found", id) + } + envGetters := make([]envGetter, 0, len(resp.App.Environments)) + for _, e := range resp.App.Environments { + if e != nil { + envGetters = append(envGetters, e) + } + } + return buildApp(resp.App.Id, resp.App.Name, resp.App.Type, resp.App.TypeId), envsFromGetters(envGetters), nil + } + + resp, err := gql.ResolveAppByName(ctx, client, appKey) + if err != nil { + return App{}, nil, fmt.Errorf("resolve app name=%q: %w", appKey, err) + } + if resp == nil || resp.Apps == nil || len(resp.Apps.Edges) == 0 || resp.Apps.Edges[0] == nil { + return App{}, nil, fmt.Errorf("no app matching name=%q found", appKey) + } + edge := resp.Apps.Edges[0] + envGetters := make([]envGetter, 0, len(edge.Environments)) + for _, e := range edge.Environments { + if e != nil { + envGetters = append(envGetters, e) + } + } + return buildApp(edge.Id, edge.Name, edge.Type, edge.TypeId), envsFromGetters(envGetters), nil +} + +func buildApp(id *int64, name *string, appType *string, typeId *int64) App { + var a App + if id != nil { + a.ID = *id + } + if name != nil { + a.Name = *name + } + if appType != nil { + a.Type = *appType + } + if typeId != nil { + a.TypeId = *typeId + } + return a +} + +func envsFromGetters(getters []envGetter) []Env { + if len(getters) == 0 { + return nil + } + out := make([]Env, 0, len(getters)) + for _, g := range getters { + e := Env{} + if id := g.GetId(); id != nil { + e.ID = *id + } + if appID := g.GetAppId(); appID != nil { + e.AppId = *appID + } + if name := g.GetName(); name != nil { + e.Name = *name + } + if typ := g.GetType(); typ != nil { + e.Type = *typ + } + if ul := g.GetUniqueLabel(); ul != nil { + e.UniqueLabel = *ul + } + if d := g.GetDefaultDomain(); d != nil { + e.DefaultDomain = *d + } + if multisite := g.GetIsMultisite(); multisite != nil { + e.IsMultisite = *multisite + } + if e.ID == 0 && e.Name == "" && e.Type == "" { + continue + } + out = append(out, e) + } + if len(out) == 0 { + return nil + } + return out +} + +func ptrIfNonEmpty(s string) *string { + if s == "" { + return nil + } + return &s +} + +// lookupFlag returns the string value of the named flag, walking both local +// and persistent flag tables on cmd (and any ancestor that propagated a +// persistent flag through pflag's lookup chain). Returns "" if the flag is +// not defined. Mirrors how interactive.go reads --non-interactive: directly +// off the *pflag.Flag so we don't depend on Cobra's lazy merge having run. +func lookupFlag(cmd *cobra.Command, name string) string { + if cmd == nil { + return "" + } + if f := cmd.Flag(name); f != nil { + return f.Value.String() + } + return "" +} diff --git a/internal/appctx/app_resolver_test.go b/internal/appctx/app_resolver_test.go new file mode 100644 index 000000000..773d786a5 --- /dev/null +++ b/internal/appctx/app_resolver_test.go @@ -0,0 +1,187 @@ +package appctx + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Khan/genqlient/graphql" + "github.com/spf13/cobra" +) + +func gqlClientForServer(srv *httptest.Server) graphql.Client { + return graphql.NewClient(srv.URL+"/graphql", srv.Client()) +} + +func makeAppCmd(app, env string, nonInteractive bool) *cobra.Command { + cmd := &cobra.Command{Use: "x"} + cmd.PersistentFlags().String("app", app, "") + cmd.PersistentFlags().String("env", env, "") + cmd.PersistentFlags().Bool("non-interactive", false, "") + if nonInteractive { + _ = cmd.PersistentFlags().Set("non-interactive", "true") + } + cmd.SetContext(context.Background()) + return cmd +} + +func TestWithAppContextResolvesByName(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":42,"name":"myapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example","isMultisite":true}]}]}}}`)) + })) + defer srv.Close() + cmd := makeAppCmd("myapp", "", true) + + mw := WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}) + called := false + run := mw(func(cmd *cobra.Command, args []string) error { + ae := FromContext(cmd.Context()) + if ae == nil || ae.App.ID != 42 || ae.App.Name != "myapp" { + t.Errorf("AppEnv = %+v", ae) + } + envs := ae.AvailableEnvs() + if len(envs) != 1 || envs[0].ID != 7 || envs[0].Type != "develop" || envs[0].DefaultDomain != "d.example" { + t.Errorf("AvailableEnvs = %+v", envs) + } + if !envs[0].IsMultisite { + t.Errorf("AvailableEnvs[0].IsMultisite = false, want true") + } + called = true + return nil + }) + if err := run(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !called { + t.Error("inner handler not called") + } +} + +func TestWithAppContextResolvesByID(t *testing.T) { + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, 4096) + n, _ := r.Body.Read(buf) + gotBody = string(buf[:n]) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"app":{"id":42,"name":"myapp","environments":[{"id":7,"name":"develop","type":"develop","isMultisite":true}]}}}`)) + })) + defer srv.Close() + cmd := makeAppCmd("42", "", true) + + mw := WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}) + called := false + run := mw(func(cmd *cobra.Command, args []string) error { + ae := FromContext(cmd.Context()) + if ae == nil || ae.App.ID != 42 || ae.App.Name != "myapp" { + t.Errorf("AppEnv = %+v", ae) + } + envs := ae.AvailableEnvs() + if len(envs) != 1 || !envs[0].IsMultisite { + t.Errorf("AvailableEnvs = %+v, want one multisite env", envs) + } + called = true + return nil + }) + if err := run(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !called { + t.Error("inner handler not called") + } + if !strings.Contains(gotBody, "ResolveAppByID") { + t.Errorf("expected ResolveAppByID in request body; got %s", gotBody) + } +} + +func TestWithAppContextMissingAppNonInteractive(t *testing.T) { + cmd := makeAppCmd("", "", true) + mw := WithAppContext(AppContextConfig{Client: nil}) + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("inner handler must not be called") + return nil + }) + err := run(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "--app") { + t.Errorf("err = %v, want missing --app error", err) + } +} + +func TestWithAppContextNotFoundByName(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[]}}}`)) + })) + defer srv.Close() + cmd := makeAppCmd("ghost", "", true) + mw := WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}) + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("inner must not be called when app not found") + return nil + }) + err := run(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "ghost") { + t.Errorf("err = %v, want not-found error mentioning the key", err) + } +} + +func TestWithAppContextNotFoundByID(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"app":null}}`)) + })) + defer srv.Close() + cmd := makeAppCmd("999", "", true) + mw := WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}) + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("inner must not be called when app not found") + return nil + }) + err := run(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "999") { + t.Errorf("err = %v, want not-found error mentioning the id", err) + } +} + +func TestWithAppContextNilClientErrors(t *testing.T) { + cmd := makeAppCmd("myapp", "", true) + mw := WithAppContext(AppContextConfig{Client: nil}) + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("inner must not be called when client is nil") + return nil + }) + err := run(cmd, nil) + if err == nil { + t.Fatal("expected an error when Client is nil") + } +} + +func TestWithAppContextPopulatesTypeId(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":42,"name":"x","typeId":3,"environments":[{"id":7,"appId":42,"name":"develop","type":"develop","defaultDomain":"d.example"}]}]}}}`)) + })) + defer srv.Close() + cmd := makeAppCmd("x", "", true) + + mw := WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}) + called := false + run := mw(func(cmd *cobra.Command, args []string) error { + ae := FromContext(cmd.Context()) + if ae == nil { + t.Fatal("AppEnv is nil") + } + if ae.App.TypeId != 3 { + t.Errorf("App.TypeId = %d, want 3", ae.App.TypeId) + } + called = true + return nil + }) + if err := run(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !called { + t.Error("inner handler not called") + } +} diff --git a/internal/appctx/confirm.go b/internal/appctx/confirm.go new file mode 100644 index 000000000..43133954e --- /dev/null +++ b/internal/appctx/confirm.go @@ -0,0 +1,180 @@ +package appctx + +import ( + "fmt" + + "github.com/AlecAivazis/survey/v2" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/output" +) + +// confirmPrompt is the seam the confirm middlewares call instead of Confirm +// directly, so tests can observe the exact prompt text (and answer it) +// without a TTY. Production value is Confirm. +var confirmPrompt = Confirm + +// ConfirmPayload contributes module-specific rows to the confirmation info +// table and may rewrite the confirm message. It is the port of the +// `switch (_opts.module)` block in src/lib/cli/command.js:858-983. +// +// It runs BEFORE anything is printed, so returning an error aborts the +// command with nothing on screen — that is how Node's sync module refuses a +// sync the server would reject (command.js:914-920 calls exit.withError from +// inside the switch, before confirm() is ever reached). +// +// message is the current confirm message; the returned string replaces it +// (import-media rewrites "the URL" -> "the path" for local archives). +type ConfirmPayload func(cmd *cobra.Command, args []string, message string) ([]output.Tuple, string, error) + +// ensureSkipConfirmationFlag registers --skip-confirmation on cmd's persistent +// flags. It is idempotent — if the flag already exists it is a no-op. +func ensureSkipConfirmationFlag(cmd *cobra.Command) { + if cmd.Flag("skip-confirmation") != nil { + return + } + // Register on PersistentFlags so subcommands inherit it, and also merge it + // into the local FlagSet so cmd.Flags().Set/GetBool work in tests and when + // Cobra hasn't yet performed its lazy persistent-flag merge. + cmd.PersistentFlags().Bool("skip-confirmation", false, "Skip confirmation prompts.") + cmd.Flags().AddFlagSet(cmd.PersistentFlags()) +} + +// WithSkipConfirmationFlag registers --skip-confirmation on cmd at apply time +// (so Cobra parses it before RunE) and returns a pass-through Middleware. +// Calling it on a cmd that already has the flag is a no-op. +func WithSkipConfirmationFlag(cmd *cobra.Command) Middleware { + ensureSkipConfirmationFlag(cmd) + return func(next RunFunc) RunFunc { + return func(c *cobra.Command, args []string) error { + return next(c, args) + } + } +} + +// WithConfirm gates execution on a production-only yes/no prompt with the +// given static message. Non-production envs proceed without prompting. +// --skip-confirmation bypasses unconditionally. Decline (or non-interactive +// context) prints "Command cancelled" to stdout and returns nil (exit 0). +func WithConfirm(cmd *cobra.Command, message string) Middleware { + ensureSkipConfirmationFlag(cmd) + return func(next RunFunc) RunFunc { + return func(c *cobra.Command, args []string) error { + // --skip-confirmation bypasses unconditionally. + if skip, _ := c.Flags().GetBool("skip-confirmation"); skip { + return next(c, args) + } + + // Production gate: only prompt on production envs. + ae := FromContext(c.Context()) + if ae == nil || ae.Env.Type != "production" { + return next(c, args) + } + + // Prompt the user. + confirmed, err := confirmPrompt(c, message, false) + if err == ErrNonInteractive || (!confirmed && err == nil) { + fmt.Fprintln(c.OutOrStdout(), "Command cancelled") + return nil + } + if err != nil { + return err + } + return next(c, args) + } + } +} + +// WithRequireConfirm gates execution on an unconditional yes/no prompt +// (no production gating). --skip-confirmation bypasses. Decline (or +// non-interactive context) prints "Command cancelled" to stdout and returns +// nil (exit 0). +// +// Node parity (src/lib/cli/command.js:840-994 + src/lib/cli/prompt.ts:14): +// an info table listing the target App and Environment — plus any +// module-specific rows contributed by `payload` — is console.logged to +// STDOUT immediately above the yes/no question. Without it users were asked +// to authorize destroying a database without being told which one. +// +// The whole block, table included, lives behind `! options.force` in Node, +// so --skip-confirmation / --force renders nothing at all and never runs the +// payload. Do not "fix" that asymmetry: a table under --skip-confirmation +// would be output Node never produces. +func WithRequireConfirm(cmd *cobra.Command, message string, payload ...ConfirmPayload) Middleware { + ensureSkipConfirmationFlag(cmd) + return func(next RunFunc) RunFunc { + return func(c *cobra.Command, args []string) error { + // --skip-confirmation bypasses unconditionally. + if skip, _ := c.Flags().GetBool("skip-confirmation"); skip { + return next(c, args) + } + + info := appEnvInfoRows(c) + for _, p := range payload { + if p == nil { + continue + } + rows, rewritten, err := p(c, args, message) + if err != nil { + return err + } + info = append(info, rows...) + message = rewritten + } + fmt.Fprintln(c.OutOrStdout(), output.KeyValue(info)) + + // Prompt the user. + confirmed, err := confirmPrompt(c, message, false) + if err == ErrNonInteractive || (!confirmed && err == nil) { + fmt.Fprintln(c.OutOrStdout(), "Command cancelled") + return nil + } + if err != nil { + return err + } + return next(c, args) + } + } +} + +// appEnvInfoRows builds the two rows every requireConfirm command shows +// (command.js:844-851). Node guards each on `options.app` / `options.env` +// being set by the appContext/envContext middleware; the Go equivalent is a +// non-zero resolved ID on the AppEnv carrier. +func appEnvInfoRows(c *cobra.Command) []output.Tuple { + ae := FromContext(c.Context()) + if ae == nil { + return nil + } + var rows []output.Tuple + if ae.App.ID != 0 { + rows = append(rows, output.Tuple{ + Key: "App", + Value: fmt.Sprintf("%s (id: %d)", ae.App.Name, ae.App.ID), + }) + } + if ae.Env.ID != 0 { + rows = append(rows, output.Tuple{ + Key: "Environment", + Value: fmt.Sprintf("%s (id: %d)", getEnvIdentifier(ae.Env), ae.Env.ID), + }) + } + return rows +} + +// Secret prompts for a masked-input secret value. Returns ErrNonInteractive +// in non-interactive contexts. +// +// Intentional Node deviation: Node uses a plain Input prompt; Go masks input +// so envvar values do not appear in terminal scrollback. Parity scenarios for +// envvar set use --from-file + --skip-confirmation to bypass. +func Secret(cmd *cobra.Command, message string) (string, error) { + if !IsInteractive(cmd) { + return "", ErrNonInteractive + } + var out string + if err := survey.AskOne(&survey.Password{Message: message}, &out); err != nil { + return "", err + } + return out, nil +} diff --git a/internal/appctx/confirm_payload_test.go b/internal/appctx/confirm_payload_test.go new file mode 100644 index 000000000..bbfa71e63 --- /dev/null +++ b/internal/appctx/confirm_payload_test.go @@ -0,0 +1,201 @@ +package appctx + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/output" +) + +// stubConfirm replaces the survey-backed prompt so tests can observe the +// message that would have been shown and choose the answer. Returns a +// restore func. +func stubConfirm(t *testing.T, answer bool, seen *string) { + t.Helper() + prev := confirmPrompt + confirmPrompt = func(_ *cobra.Command, message string, _ bool) (bool, error) { + if seen != nil { + *seen = message + } + return answer, nil + } + t.Cleanup(func() { confirmPrompt = prev }) +} + +func requireConfirmCmd(t *testing.T, ae *AppEnv) (*cobra.Command, *bytes.Buffer) { + t.Helper() + t.Setenv("NO_COLOR", "1") + cmd := &cobra.Command{Use: "x"} + var stdout bytes.Buffer + cmd.SetOut(&stdout) + if ae != nil { + cmd.SetContext(WithAppEnv(context.Background(), ae)) + } else { + cmd.SetContext(context.Background()) + } + return cmd, &stdout +} + +// Node's requireConfirm builds an info table and confirm() console.logs it +// ABOVE the yes/no prompt (command.js:840-851 + prompt.ts:14). vip-next +// printed only the message, so users authorized destructive actions without +// being told which app/environment they targeted. +func TestWithRequireConfirmRendersAppAndEnvironmentRows(t *testing.T) { + cmd, stdout := requireConfirmCmd(t, &AppEnv{ + App: App{ID: 42, Name: "my-app"}, + Env: Env{ID: 7, AppId: 7, Type: "develop", Name: "develop"}, + }) + stubConfirm(t, true, nil) + + mw := WithRequireConfirm(cmd, "Are you sure you want to sync from production?") + called := false + if err := mw(func(*cobra.Command, []string) error { called = true; return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !called { + t.Fatal("handler must run after a yes") + } + + want := "===================================\n" + + "+ App: my-app (id: 42)\n" + + "+ Environment: develop (id: 7)\n" + + "===================================\n" + if stdout.String() != want { + t.Errorf("info table mismatch\n got: %q\nwant: %q", stdout.String(), want) + } +} + +// getEnvIdentifier disambiguates sibling envs of the same type, so a +// non-main env renders as "type.name". +func TestWithRequireConfirmEnvironmentRowUsesEnvIdentifier(t *testing.T) { + cmd, stdout := requireConfirmCmd(t, &AppEnv{ + App: App{ID: 42, Name: "my-app"}, + Env: Env{ID: 9, AppId: 7, Type: "develop", Name: "second"}, + }) + stubConfirm(t, true, nil) + + mw := WithRequireConfirm(cmd, "Are you sure?") + if err := mw(func(*cobra.Command, []string) error { return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !strings.Contains(stdout.String(), "+ Environment: develop.second (id: 9)\n") { + t.Errorf("want Environment row using getEnvIdentifier; got %q", stdout.String()) + } +} + +// Node's whole requireConfirm block — including the console.log of the +// info table — is inside `if (_opts.requireConfirm && ! options.force)`. +// --force / --skip-confirmation therefore prints NOTHING. +func TestWithRequireConfirmSkipFlagPrintsNoInfoTable(t *testing.T) { + cmd, stdout := requireConfirmCmd(t, &AppEnv{ + App: App{ID: 42, Name: "my-app"}, + Env: Env{ID: 7, AppId: 7, Type: "develop", Name: "develop"}, + }) + mw := WithRequireConfirm(cmd, "Are you sure?") + _ = cmd.Flags().Set("skip-confirmation", "true") + if err := mw(func(*cobra.Command, []string) error { return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if stdout.Len() != 0 { + t.Errorf("--skip-confirmation must print nothing; got %q", stdout.String()) + } +} + +// The module rows (command.js:858-983) are appended AFTER App/Environment. +func TestWithRequireConfirmAppendsModulePayloadRows(t *testing.T) { + cmd, stdout := requireConfirmCmd(t, &AppEnv{ + App: App{ID: 1, Name: "app"}, + Env: Env{ID: 2, AppId: 2, Type: "production", Name: "production"}, + }) + stubConfirm(t, true, nil) + + payload := func(*cobra.Command, []string, string) ([]output.Tuple, string, error) { + return []output.Tuple{{Key: "From backup", Value: "Mon, 21 Jul 2025 10:11:12 GMT"}}, "Are you sure?", nil + } + mw := WithRequireConfirm(cmd, "Are you sure?", payload) + if err := mw(func(*cobra.Command, []string) error { return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + want := "===================================\n" + + "+ App: app (id: 1)\n" + + "+ Environment: production (id: 2)\n" + + "+ From backup: Mon, 21 Jul 2025 10:11:12 GMT\n" + + "===================================\n" + if stdout.String() != want { + t.Errorf("info table mismatch\n got: %q\nwant: %q", stdout.String(), want) + } +} + +// The sync module's canSync guard exits BEFORE the prompt and before the +// destructive mutation. A payload error must abort the whole chain and must +// not render a table or invoke the handler. +func TestWithRequireConfirmPayloadErrorAbortsBeforeHandler(t *testing.T) { + cmd, stdout := requireConfirmCmd(t, &AppEnv{App: App{ID: 1, Name: "app"}}) + stubConfirm(t, true, nil) + + boom := errors.New("Could not sync to this environment: nope") + payload := func(*cobra.Command, []string, string) ([]output.Tuple, string, error) { + return nil, "", boom + } + called := false + mw := WithRequireConfirm(cmd, "Are you sure?", payload) + err := mw(func(*cobra.Command, []string) error { called = true; return nil })(cmd, nil) + if !errors.Is(err, boom) { + t.Fatalf("err = %v, want %v", err, boom) + } + if called { + t.Error("handler must not run when the payload refuses") + } + if stdout.Len() != 0 { + t.Errorf("no info table should be printed on refusal; got %q", stdout.String()) + } +} + +// import-media rewrites "the URL" -> "the path" for local archives +// (command.js:944-947), so a payload must be able to replace the message. +func TestWithRequireConfirmPayloadCanRewriteMessage(t *testing.T) { + cmd, _ := requireConfirmCmd(t, &AppEnv{App: App{ID: 1, Name: "app"}}) + var seen string + stubConfirm(t, true, &seen) + + payload := func(_ *cobra.Command, _ []string, message string) ([]output.Tuple, string, error) { + return nil, strings.ReplaceAll(message, "the URL", "the path"), nil + } + mw := WithRequireConfirm(cmd, "Are you sure you want to import the contents of the URL?", payload) + if err := mw(func(*cobra.Command, []string) error { return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if seen != "Are you sure you want to import the contents of the path?" { + t.Errorf("prompt message = %q", seen) + } +} + +// A declined prompt still leaves the table on screen (Node prints it first) +// and cancels with exit 0. +func TestWithRequireConfirmDeclineStillRendersTable(t *testing.T) { + cmd, stdout := requireConfirmCmd(t, &AppEnv{ + App: App{ID: 42, Name: "my-app"}, + Env: Env{ID: 7, AppId: 7, Type: "develop", Name: "develop"}, + }) + stubConfirm(t, false, nil) + + called := false + mw := WithRequireConfirm(cmd, "Are you sure?") + if err := mw(func(*cobra.Command, []string) error { called = true; return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if called { + t.Error("handler must not run after a no") + } + if !strings.Contains(stdout.String(), "+ App: my-app (id: 42)") { + t.Errorf("table must be printed before the prompt; got %q", stdout.String()) + } + if !strings.Contains(stdout.String(), "Command cancelled") { + t.Errorf("want 'Command cancelled'; got %q", stdout.String()) + } +} diff --git a/internal/appctx/confirm_test.go b/internal/appctx/confirm_test.go new file mode 100644 index 000000000..450f08642 --- /dev/null +++ b/internal/appctx/confirm_test.go @@ -0,0 +1,120 @@ +package appctx + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestWithSkipConfirmationFlagRegistersAtApplyTime(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + _ = WithSkipConfirmationFlag(cmd) + if cmd.Flag("skip-confirmation") == nil { + t.Fatal("--skip-confirmation must be registered at apply time so Cobra parses it before RunE") + } + // Idempotent: applying again on the same cmd must not panic / double-register. + _ = WithSkipConfirmationFlag(cmd) +} + +func TestWithConfirmSkipsPromptWhenSkipFlagSet(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + mw := WithConfirm(cmd, "Are you sure?") + cmd.SetContext(WithAppEnv(context.Background(), &AppEnv{ + App: App{ID: 1, Name: "myapp"}, + Env: Env{ID: 2, Type: "production"}, + })) + _ = cmd.Flags().Set("skip-confirmation", "true") + called := false + if err := mw(func(*cobra.Command, []string) error { called = true; return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !called { + t.Error("handler must run when --skip-confirmation is set (no prompt)") + } +} + +func TestWithConfirmSkipsNonProduction(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + mw := WithConfirm(cmd, "Are you sure?") + cmd.SetContext(WithAppEnv(context.Background(), &AppEnv{ + App: App{ID: 1, Name: "myapp"}, + Env: Env{ID: 2, Type: "develop"}, + })) + called := false + if err := mw(func(*cobra.Command, []string) error { called = true; return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !called { + t.Error("non-production envs must skip the confirm prompt") + } +} + +func TestWithConfirmProdNonInteractiveCancels(t *testing.T) { + // VIP_NON_INTERACTIVE=1 makes IsInteractive return false; Confirm + // returns ErrNonInteractive; the middleware treats that as decline, + // prints "Command cancelled" to stdout, returns nil (exit 0 — user-cancel != error). + t.Setenv("VIP_NON_INTERACTIVE", "1") + cmd := &cobra.Command{Use: "x"} + mw := WithConfirm(cmd, "Are you sure?") + cmd.SetContext(WithAppEnv(context.Background(), &AppEnv{ + App: App{ID: 1, Name: "myapp"}, + Env: Env{ID: 2, Type: "production"}, + })) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + called := false + if err := mw(func(*cobra.Command, []string) error { called = true; return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if called { + t.Error("non-interactive prod confirm should cancel without invoking handler") + } + if !strings.Contains(stdout.String(), "Command cancelled") { + t.Errorf("stdout must contain 'Command cancelled'; got %q", stdout.String()) + } +} + +func TestWithRequireConfirmSkipsPromptWhenSkipFlagSet(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + mw := WithRequireConfirm(cmd, "Are you sure you want to do the thing?") + cmd.SetContext(context.Background()) + _ = cmd.Flags().Set("skip-confirmation", "true") + called := false + if err := mw(func(*cobra.Command, []string) error { called = true; return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !called { + t.Error("handler must run when --skip-confirmation is set") + } +} + +func TestWithRequireConfirmNonInteractiveCancels(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + cmd := &cobra.Command{Use: "x"} + mw := WithRequireConfirm(cmd, "Are you sure?") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + called := false + if err := mw(func(*cobra.Command, []string) error { called = true; return nil })(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if called { + t.Error("non-interactive WithRequireConfirm should cancel without invoking handler") + } + if !strings.Contains(stdout.String(), "Command cancelled") { + t.Errorf("stdout must contain 'Command cancelled'; got %q", stdout.String()) + } +} + +func TestSecretNonInteractiveReturnsErr(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + cmd := &cobra.Command{Use: "x"} + _, err := Secret(cmd, "Enter the value:") + if !errors.Is(err, ErrNonInteractive) { + t.Errorf("err = %v, want ErrNonInteractive", err) + } +} diff --git a/internal/appctx/context.go b/internal/appctx/context.go new file mode 100644 index 000000000..8b294362e --- /dev/null +++ b/internal/appctx/context.go @@ -0,0 +1,94 @@ +// Package appctx — context helpers for command-middleware. WithAppContext +// (Task 8) and WithEnvContext (Task 9) stash resolved metadata here so +// handlers can fetch it via FromContext(cmd.Context()). +package appctx + +import "context" + +// App is the resolved app metadata. Mirrors src/lib/api/app.ts return shape. +// TypeId is the platform site-type identifier (e.g. 1 = Node.js). It defaults +// to 0 when the server omits the field (legacy fixtures, older API versions), +// which is interpreted as "unknown / not Node.js" — preserving Node parity for +// callers that branch on TypeId == 1. +type App struct { + ID int64 + Name string + TypeId int64 + // Type is the human-readable application type (e.g. "WordPress", + // "node"). Media-import commands gate on it (media-file-import.ts:18). + Type string +} + +// Env is a resolved environment. DefaultDomain is a String scalar in the +// schema (not an object), so it's a plain Go string here — matches the +// shape ResolveAppByName / ResolveAppByID produce. +// +// AppId mirrors the schema's AppEnvironment.appId. It identifies the "main" +// env (Node parity: `env.appId === env.id` marks the env that owns the +// canonical app slug; see getEnvIdentifier in env_resolver.go). +type Env struct { + ID int64 + AppId int64 + Name string + Type string // "production" | "develop" | "staging" | ... + DefaultDomain string + // UniqueLabel is the env's dashboard slug (e.g. "develop"); used in + // dashboard URLs by export sql and app deploy. + UniqueLabel string + IsMultisite bool +} + +// AppEnv pairs the resolved App with its target Env. Either field may be +// zero-valued: WithAppContext sets only App + envs; WithEnvContext narrows +// to a single Env from the envs list. +type AppEnv struct { + App App + Env Env + envs []Env // populated by WithAppContext; consumed by WithEnvContext +} + +// AvailableEnvs returns the candidate envs from the resolved App. Used by +// WithEnvContext (Task 9) for auto-select / prompt / lookup. Returns a copy +// so callers can't mutate the carrier's slice. +func (a *AppEnv) AvailableEnvs() []Env { + if a == nil || len(a.envs) == 0 { + return nil + } + out := make([]Env, len(a.envs)) + copy(out, a.envs) + return out +} + +// SetAvailableEnvs replaces the candidate-envs list. Package-internal use +// by app_resolver.go (Task 8). Stores a copy to avoid aliasing the caller's +// slice into the carrier. +func (a *AppEnv) SetAvailableEnvs(envs []Env) { + if a == nil { + return + } + if len(envs) == 0 { + a.envs = nil + return + } + a.envs = make([]Env, len(envs)) + copy(a.envs, envs) +} + +type appEnvKey struct{} + +// WithAppEnv returns a new context carrying ae. +func WithAppEnv(ctx context.Context, ae *AppEnv) context.Context { + return context.WithValue(ctx, appEnvKey{}, ae) +} + +// FromContext extracts the AppEnv set by WithAppEnv, or nil if absent. +// Returns nil if ctx is nil — cobra.Command.Context() can be nil when no +// SetContext / ExecuteContext has run, so middleware that probes for AppEnv +// must not panic on that path. +func FromContext(ctx context.Context) *AppEnv { + if ctx == nil { + return nil + } + v, _ := ctx.Value(appEnvKey{}).(*AppEnv) + return v +} diff --git a/internal/appctx/context_test.go b/internal/appctx/context_test.go new file mode 100644 index 000000000..3e10123c8 --- /dev/null +++ b/internal/appctx/context_test.go @@ -0,0 +1,56 @@ +package appctx + +import ( + "context" + "testing" +) + +func TestAppEnvRoundTrip(t *testing.T) { + ctx := context.Background() + ae := &AppEnv{ + App: App{ID: 42, Name: "myapp"}, + Env: Env{ID: 7, Name: "develop", Type: "develop"}, + } + ctx = WithAppEnv(ctx, ae) + got := FromContext(ctx) + if got == nil { + t.Fatal("FromContext returned nil") + } + if got.App.ID != 42 || got.App.Name != "myapp" { + t.Errorf("App = %+v", got.App) + } + if got.Env.ID != 7 || got.Env.Type != "develop" { + t.Errorf("Env = %+v", got.Env) + } +} + +func TestFromContextEmpty(t *testing.T) { + if got := FromContext(context.Background()); got != nil { + t.Errorf("FromContext(empty) = %+v, want nil", got) + } +} + +func TestFromContextIgnoresOtherKeys(t *testing.T) { + type otherKey struct{} + ctx := context.WithValue(context.Background(), otherKey{}, "intruder") + if got := FromContext(ctx); got != nil { + t.Errorf("FromContext on unrelated key = %+v, want nil", got) + } +} + +// AvailableEnvs returns the env list populated by WithAppContext for +// WithEnvContext to consume. This test pins the contract. +func TestAvailableEnvsRoundTrip(t *testing.T) { + ae := &AppEnv{App: App{ID: 1, Name: "a"}} + ae.SetAvailableEnvs([]Env{ + {ID: 1, Name: "production", Type: "production"}, + {ID: 2, Name: "develop", Type: "develop"}, + }) + got := ae.AvailableEnvs() + if len(got) != 2 { + t.Fatalf("AvailableEnvs len = %d, want 2", len(got)) + } + if got[0].Type != "production" || got[1].Type != "develop" { + t.Errorf("envs = %+v", got) + } +} diff --git a/internal/appctx/env_resolver.go b/internal/appctx/env_resolver.go new file mode 100644 index 000000000..e9a104c51 --- /dev/null +++ b/internal/appctx/env_resolver.go @@ -0,0 +1,110 @@ +package appctx + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +// WithEnvContext expects WithAppContext to have run earlier in the chain. +// It picks the target Env from AppEnv.AvailableEnvs() using --env, or auto- +// selects when the app has exactly one env, or prompts (interactive), +// or errors (non-interactive with multiple envs). +func WithEnvContext() Middleware { + return func(next RunFunc) RunFunc { + return func(cmd *cobra.Command, args []string) error { + ae := FromContext(cmd.Context()) + if ae == nil { + return fmt.Errorf("WithEnvContext requires WithAppContext earlier in the chain") + } + envs := ae.AvailableEnvs() + + envFlag := "" + if f := cmd.Flag("env"); f != nil { + envFlag = f.Value.String() + } + + if envFlag == "" { + switch len(envs) { + case 0: + return fmt.Errorf("app %q has no environments", ae.App.Name) + case 1: + ae.Env = envs[0] + cmd.SetContext(WithAppEnv(cmd.Context(), ae)) + return next(cmd, args) + default: + ids := envIdentifiers(envs) + if !IsInteractive(cmd) { + return fmt.Errorf("--env is required (one of %s)", strings.Join(ids, ", ")) + } + picked, err := Select(cmd, + fmt.Sprintf("Choose an environment for %s:", ae.App.Name), ids) + if err != nil { + return fmt.Errorf("--env is required (one of %s): %w", + strings.Join(ids, ", "), err) + } + envFlag = picked + } + } + + needle := strings.ToLower(envFlag) + for _, e := range envs { + if strings.ToLower(getEnvIdentifier(e)) == needle { + ae.Env = e + cmd.SetContext(WithAppEnv(cmd.Context(), ae)) + return next(cmd, args) + } + } + return fmt.Errorf("environment %q not found on app %q; available: %s", + envFlag, ae.App.Name, strings.Join(envIdentifiers(envs), ", ")) + } + } +} + +// WithChildEnvContext is WithEnvContext + rejection of production envs. +// Mirrors Node's _opts.childEnvContext; used by destructive commands that +// must never run on production. +func WithChildEnvContext() Middleware { + inner := WithEnvContext() + return func(next RunFunc) RunFunc { + return inner(func(cmd *cobra.Command, args []string) error { + ae := FromContext(cmd.Context()) + if ae != nil && ae.Env.Type == "production" { + return fmt.Errorf("this command cannot run on production environments") + } + return next(cmd, args) + }) + } +} + +func envNames(envs []Env) []string { + out := make([]string, 0, len(envs)) + for _, e := range envs { + out = append(out, e.Name) + } + return out +} + +// getEnvIdentifier ports Node's src/lib/cli/command.js helper of the same +// name. For the canonical "main" env on an app (where env.appId == env.id) +// it returns env.type ("production", "develop", ...). For sibling envs of +// the same type (the disambiguating case) it returns "type.name". +// +// This is what users type as the env half of @app.env aliases — matching +// must be case-insensitive against this identifier, NOT env.name alone. +func getEnvIdentifier(e Env) string { + identifier := e.Type + if e.Name != "" && e.Name != e.Type && e.AppId != e.ID { + identifier = e.Type + "." + e.Name + } + return identifier +} + +func envIdentifiers(envs []Env) []string { + out := make([]string, 0, len(envs)) + for _, e := range envs { + out = append(out, getEnvIdentifier(e)) + } + return out +} diff --git a/internal/appctx/env_resolver_test.go b/internal/appctx/env_resolver_test.go new file mode 100644 index 000000000..8299b3e80 --- /dev/null +++ b/internal/appctx/env_resolver_test.go @@ -0,0 +1,215 @@ +package appctx + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func makeEnvCmd(app, env string, nonInteractive bool) *cobra.Command { + cmd := &cobra.Command{Use: "x"} + cmd.PersistentFlags().String("app", app, "") + cmd.PersistentFlags().String("env", env, "") + cmd.PersistentFlags().Bool("non-interactive", false, "") + if nonInteractive { + _ = cmd.PersistentFlags().Set("non-interactive", "true") + } + cmd.SetContext(context.Background()) + return cmd +} + +// TestWithEnvContextResolvesByTypeMainEnv reproduces the @app.production +// alias case: env.name = app slug, env.type = "production", env.appId = +// env.id (main env). Node's getEnvIdentifier returns env.type here, so +// `--env=production` must match. +func TestWithEnvContextResolvesByTypeMainEnv(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":3453,"name":"cantina-trunk-staging","environments":[{"id":3453,"appId":3453,"name":"cantina-trunk-staging","type":"production","defaultDomain":"www.example"}]}]}}}`)) + })) + defer srv.Close() + cmd := makeEnvCmd("cantina-trunk-staging", "production", true) + mw := Chain( + WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}), + WithEnvContext(), + ) + run := mw(func(cmd *cobra.Command, args []string) error { + ae := FromContext(cmd.Context()) + if ae == nil || ae.Env.Type != "production" || ae.Env.ID != 3453 { + t.Errorf("Env = %+v", ae) + } + return nil + }) + if err := run(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } +} + +func TestWithEnvContextResolves(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":42,"name":"myapp","environments":[{"id":7,"name":"develop","type":"develop","defaultDomain":"d.example"},{"id":1,"name":"production","type":"production","defaultDomain":"p.example"}]}]}}}`)) + })) + defer srv.Close() + cmd := makeEnvCmd("myapp", "develop", true) + mw := Chain( + WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}), + WithEnvContext(), + ) + run := mw(func(cmd *cobra.Command, args []string) error { + ae := FromContext(cmd.Context()) + if ae == nil || ae.Env.ID != 7 || ae.Env.Name != "develop" || ae.Env.Type != "develop" { + t.Errorf("Env = %+v", ae) + } + return nil + }) + if err := run(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } +} + +func TestWithEnvContextEnvNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":42,"name":"myapp","environments":[{"id":7,"name":"develop","type":"develop"}]}]}}}`)) + })) + defer srv.Close() + cmd := makeEnvCmd("myapp", "ghostenv", true) + mw := Chain( + WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}), + WithEnvContext(), + ) + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("must not be called") + return nil + }) + err := run(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "ghostenv") { + t.Errorf("err = %v, want not-found", err) + } + if !strings.Contains(err.Error(), "develop") { + t.Errorf("err should list available envs; got %v", err) + } +} + +func TestWithEnvContextAutoSelectsSingleEnv(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":42,"name":"myapp","environments":[{"id":7,"name":"develop","type":"develop"}]}]}}}`)) + })) + defer srv.Close() + cmd := makeEnvCmd("myapp", "", true) + mw := Chain( + WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}), + WithEnvContext(), + ) + run := mw(func(cmd *cobra.Command, args []string) error { + ae := FromContext(cmd.Context()) + if ae == nil || ae.Env.ID != 7 { + t.Errorf("Env = %+v", ae) + } + return nil + }) + if err := run(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } +} + +func TestWithEnvContextMultipleNonInteractiveRequiresFlag(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":42,"name":"myapp","environments":[{"id":7,"name":"develop","type":"develop"},{"id":1,"name":"production","type":"production"}]}]}}}`)) + })) + defer srv.Close() + cmd := makeEnvCmd("myapp", "", true) + mw := Chain( + WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}), + WithEnvContext(), + ) + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("must not be called without --env") + return nil + }) + err := run(cmd, nil) + if err == nil { + t.Fatal("expected error when --env required and not set in non-interactive mode") + } + if !strings.Contains(err.Error(), "develop") || !strings.Contains(err.Error(), "production") { + t.Errorf("err should list available envs; got %v", err) + } +} + +func TestWithEnvContextNoEnvs(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":42,"name":"myapp","environments":[]}]}}}`)) + })) + defer srv.Close() + cmd := makeEnvCmd("myapp", "", true) + mw := Chain( + WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}), + WithEnvContext(), + ) + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("must not be called") + return nil + }) + err := run(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "myapp") { + t.Errorf("err = %v, want error mentioning the app has no envs", err) + } +} + +func TestWithEnvContextMissingAppCtxErrors(t *testing.T) { + cmd := makeEnvCmd("myapp", "develop", true) + mw := WithEnvContext() + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("must not be called when AppContext missing") + return nil + }) + err := run(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "WithAppContext") { + t.Errorf("err = %v, want a clear 'WithAppContext required earlier in chain' error", err) + } +} + +func TestWithChildEnvContextRejectsProduction(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":42,"name":"myapp","environments":[{"id":1,"name":"production","type":"production"}]}]}}}`)) + })) + defer srv.Close() + cmd := makeEnvCmd("myapp", "production", true) + mw := Chain( + WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}), + WithChildEnvContext(), + ) + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("must not be called for production") + return nil + }) + err := run(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "production") { + t.Errorf("err = %v, want production rejection", err) + } +} + +func TestWithChildEnvContextAllowsDevelop(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"apps":{"edges":[{"id":42,"name":"myapp","environments":[{"id":7,"name":"develop","type":"develop"}]}]}}}`)) + })) + defer srv.Close() + cmd := makeEnvCmd("myapp", "develop", true) + mw := Chain( + WithAppContext(AppContextConfig{Client: gqlClientForServer(srv)}), + WithChildEnvContext(), + ) + called := false + run := mw(func(cmd *cobra.Command, args []string) error { + called = true + return nil + }) + if err := run(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !called { + t.Error("inner not called on develop env") + } +} diff --git a/internal/appctx/format.go b/internal/appctx/format.go new file mode 100644 index 000000000..284285f6d --- /dev/null +++ b/internal/appctx/format.go @@ -0,0 +1,72 @@ +package appctx + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/output" +) + +// RenderableRunFunc is the handler shape WithFormat wraps. Handlers return +// data (one of output.HeaderData | output.OrderedRows | output.Rows | nil) +// plus an error. WithFormat dispatches the data to output.Render with the +// validated format. +type RenderableRunFunc func(cmd *cobra.Command, args []string) (any, error) + +type formatKey struct{} + +// WithFormat adds the --format flag (default defaultFormat), validates against +// `allowed`, stashes the resolved format in cmd.Context() (read via +// FormatFromContext), and wraps the handler return through output.Render. +// +// cmd is the cobra.Command the flag should be registered on. --format is +// registered immediately at apply time (not lazily inside RunE) so cobra can +// parse it before the command runs. +// +// Use via Builder.WithRenderableRun so the (any, error) shape is preserved. +func WithFormat(cmd *cobra.Command, defaultFormat string, allowed ...string) func(RenderableRunFunc) RenderableRunFunc { + allowedSet := make(map[string]bool, len(allowed)) + for _, a := range allowed { + allowedSet[a] = true + } + // Register the flag immediately at apply time so cobra can parse it before + // RunE is invoked. Previously this was done lazily inside the closure, + // which caused "unknown flag: --format" errors at parse time. + ensureFormatFlag(cmd, defaultFormat) + return func(next RenderableRunFunc) RenderableRunFunc { + return func(cmd *cobra.Command, args []string) (any, error) { + f, _ := cmd.Flags().GetString("format") + if f == "" { + f = defaultFormat + } + if !allowedSet[f] { + return nil, fmt.Errorf("Invalid format: %s. The supported formats are: %s.", + f, strings.Join(allowed, ", ")) + } + cmd.SetContext(context.WithValue(cmd.Context(), formatKey{}, output.Format(f))) + data, err := next(cmd, args) + if err != nil { + return nil, err + } + return data, output.Render(cmd.OutOrStdout(), output.Format(f), data) + } + } +} + +func ensureFormatFlag(cmd *cobra.Command, defaultFormat string) { + if cmd.Flags().Lookup("format") == nil { + cmd.Flags().String("format", defaultFormat, + "Render output in a particular format.") + } +} + +// FormatFromContext returns the format resolved by WithFormat, or empty. +func FormatFromContext(ctx context.Context) output.Format { + if v, ok := ctx.Value(formatKey{}).(output.Format); ok { + return v + } + return "" +} diff --git a/internal/appctx/format_test.go b/internal/appctx/format_test.go new file mode 100644 index 000000000..3f18fd6e0 --- /dev/null +++ b/internal/appctx/format_test.go @@ -0,0 +1,75 @@ +package appctx + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/output" +) + +// TestWithFormatRegistersFlagBeforeParse pins the bug where ensureFormatFlag +// was called lazily inside the RunE closure. With the fix, WithFormat must +// register --format at apply time so cobra can parse it before RunE runs. +func TestWithFormatRegistersFlagBeforeParse(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + WithFormat(cmd, "table", "table", "json") + if cmd.Flag("format") == nil { + t.Fatal("WithFormat must register --format at apply time, not lazily inside RunE") + } +} + +func TestWithFormatDefaultsRendersTable(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetContext(context.Background()) + + mw := WithFormat(cmd, "table", "table", "csv", "json") + run := mw(func(cmd *cobra.Command, args []string) (any, error) { + return output.OrderedRows{{{Key: "id", Value: 1}}}, nil + }) + if _, err := run(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if !strings.Contains(buf.String(), "1") { + t.Errorf("table output missing data: %s", buf.String()) + } +} + +func TestWithFormatRejectsUnknownFormat(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + cmd.SetContext(context.Background()) + cmd.Flags().String("format", "yaml", "") + mw := WithFormat(cmd, "table", "table", "csv") + run := mw(func(cmd *cobra.Command, args []string) (any, error) { + t.Error("handler must not run on rejected format") + return nil, nil + }) + _, err := run(cmd, nil) + wantSubstr := "Invalid format: yaml. The supported formats are: table, csv." + if err == nil || !strings.Contains(err.Error(), wantSubstr) { + t.Errorf("err = %v, want contains %q", err, wantSubstr) + } +} + +func TestWithFormatExposesViaFormatFromContext(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + cmd.SetContext(context.Background()) + cmd.Flags().String("format", "json", "") + mw := WithFormat(cmd, "table", "table", "json") + var seen output.Format + run := mw(func(cmd *cobra.Command, args []string) (any, error) { + seen = FormatFromContext(cmd.Context()) + return nil, nil + }) + if _, err := run(cmd, nil); err != nil { + t.Fatalf("run: %v", err) + } + if seen != output.FormatJSON { + t.Errorf("FormatFromContext = %q, want json", seen) + } +} diff --git a/internal/appctx/interactive.go b/internal/appctx/interactive.go new file mode 100644 index 000000000..667a16d9a --- /dev/null +++ b/internal/appctx/interactive.go @@ -0,0 +1,50 @@ +package appctx + +import ( + "os" + + "github.com/spf13/cobra" + "golang.org/x/term" +) + +// IsInteractive reports whether prompts and browser opens are appropriate for +// the current invocation. Single source of truth for anything cobra drives; it +// replaced defensivemode.IsInteractive, since deleted along with the rest of an +// unused helper file. rechallenge.IsInteractiveContext survives as the fallback +// for the step-up middleware, which is built before any command is parsed and +// so has no *cobra.Command to read. +// +// Precedence: +// 1. VIP_NON_INTERACTIVE=1 -> false +// 2. --non-interactive flag (on cmd or any ancestor via PersistentFlags) -> false +// 3. stdin is a TTY -> true; otherwise false. +// +// The sensor is STDIN because that is the descriptor an answer has to arrive +// on. It used to be stdout, which meant `vip sync … | tee`, `> log` or `| less` +// reported "Command cancelled" and exited 0 with the mutation never issued +// (parity blocker B5). Node's enquirer reads stdin and is likewise unaffected +// by stdout redirection. +// +// This is deliberately NOT the same question as "can I render progress?" — +// that one is about where bytes are safe to draw and is sensed separately on +// os.Stderr (commands/progress_renderer.go, commands/sync.go), so that progress +// stays out of a redirected stdout and can never corrupt --format json. +func IsInteractive(cmd *cobra.Command) bool { + return isInteractiveCheck(cmd, term.IsTerminal(int(os.Stdin.Fd()))) +} + +func isInteractiveCheck(cmd *cobra.Command, tty bool) bool { + if os.Getenv("VIP_NON_INTERACTIVE") == "1" { + return false + } + if cmd != nil { + // cmd.Flag walks the local + persistent flag tables — covers ancestors + // when PersistentFlags propagation has merged through ParseFlags. Read + // the value off the *pflag.Flag directly (Flags().GetBool fails before + // Cobra's lazy persistent-flag merge has run). + if f := cmd.Flag("non-interactive"); f != nil && f.Changed && f.Value.String() == "true" { + return false + } + } + return tty +} diff --git a/internal/appctx/interactive_test.go b/internal/appctx/interactive_test.go new file mode 100644 index 000000000..9a1f790c8 --- /dev/null +++ b/internal/appctx/interactive_test.go @@ -0,0 +1,134 @@ +package appctx + +import ( + "os" + "testing" + + "github.com/creack/pty" + "github.com/spf13/cobra" +) + +// swapStdio points os.Stdin/os.Stdout at the given files for the duration of +// the test. The existing isInteractiveCheck tests inject the tty bool, so they +// pass no matter WHICH descriptor the real IsInteractive senses — these two +// tests pin that down. +func swapStdio(t *testing.T, in, out *os.File) { + t.Helper() + origIn, origOut := os.Stdin, os.Stdout + os.Stdin, os.Stdout = in, out + t.Cleanup(func() { os.Stdin, os.Stdout = origIn, origOut }) +} + +func openPTY(t *testing.T) *os.File { + t.Helper() + ptmx, tty, err := pty.Open() + if err != nil { + t.Skipf("pty unavailable: %v", err) + } + t.Cleanup(func() { _ = ptmx.Close(); _ = tty.Close() }) + return tty +} + +func regularFile(t *testing.T) *os.File { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "redirected") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = f.Close() }) + return f +} + +func interactiveTestCmd() *cobra.Command { + cmd := &cobra.Command{Use: "x"} + cmd.PersistentFlags().Bool("non-interactive", false, "") + return cmd +} + +// Regression for parity blocker B5. Interactivity was sensed on os.Stdout, so +// `vip sync … > log`, `| tee` or `| less` printed "Command cancelled" and exited +// 0 with the mutation never issued — the user believed the sync had run. Node's +// enquirer reads stdin and is unaffected by stdout redirection. +func TestIsInteractiveSensesStdinNotStdout(t *testing.T) { + swapStdio(t, openPTY(t), regularFile(t)) + if !IsInteractive(interactiveTestCmd()) { + t.Error("stdin is a TTY and only stdout is redirected: prompting must still be possible") + } +} + +// The converse: a piped stdin cannot answer a prompt, even when stdout is a +// terminal (`vip sync < /dev/null` must not block waiting for an answer). +func TestIsInteractiveFalseWhenStdinIsNotATTY(t *testing.T) { + swapStdio(t, regularFile(t), openPTY(t)) + if IsInteractive(interactiveTestCmd()) { + t.Error("stdin is not a TTY: prompting is impossible regardless of stdout") + } +} + +func TestIsInteractiveDefaults(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + cmd.PersistentFlags().Bool("non-interactive", false, "") + if got := isInteractiveCheck(cmd, true); !got { + t.Errorf("interactive in a TTY with no overrides should be true, got %v", got) + } + if got := isInteractiveCheck(cmd, false); got { + t.Errorf("non-TTY should be false") + } +} + +func TestIsInteractiveHonorsFlag(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + cmd.PersistentFlags().Bool("non-interactive", false, "") + // Set via the PersistentFlags bucket the flag was defined on. Cobra only + // merges persistent flags into the local Flags() set lazily (during + // ParseFlags/Execute), so a direct Flags().Set on a never-executed command + // would fail. By real-command-execution time the merge has happened and + // cmd.Flag("non-interactive") finds it regardless. + if err := cmd.PersistentFlags().Set("non-interactive", "true"); err != nil { + t.Fatalf("flag set: %v", err) + } + if isInteractiveCheck(cmd, true) { + t.Error("--non-interactive must disable interactivity even on TTY") + } +} + +func TestIsInteractiveHonorsEnv(t *testing.T) { + t.Setenv("VIP_NON_INTERACTIVE", "1") + cmd := &cobra.Command{Use: "x"} + cmd.PersistentFlags().Bool("non-interactive", false, "") + if isInteractiveCheck(cmd, true) { + t.Error("VIP_NON_INTERACTIVE=1 must disable interactivity") + } +} + +// PersistentFlags propagation: a flag defined on parent must be honored when +// the test passes the child command in. (Cobra resolves PersistentFlags +// through cmd.Flag() on subcommands; this test pins the contract that the +// implementation walks the command tree correctly.) +func TestIsInteractiveHonorsPersistentFlagOnParent(t *testing.T) { + parent := &cobra.Command{Use: "parent"} + parent.PersistentFlags().Bool("non-interactive", false, "") + child := &cobra.Command{Use: "child"} + parent.AddCommand(child) + // Cobra normally executes the full command tree (which propagates flags); + // in unit tests we trigger the merge by calling Execute or ParseFlags. + parent.SetArgs([]string{"child", "--non-interactive=true"}) + if err := parent.Execute(); err != nil { + // child has no RunE — Execute returns the "no RunE" error or similar; + // that's fine, we only need the flag-parsing side-effect. + _ = err + } + if isInteractiveCheck(child, true) { + t.Error("--non-interactive defined on parent (PersistentFlags) must disable interactivity for child") + } +} + +func TestIsInteractiveNilCmd(t *testing.T) { + // Defensive: a nil cobra command shouldn't panic; treat as if no flag is set. + if got := isInteractiveCheck(nil, true); !got { + t.Errorf("nil cmd + TTY should default to interactive=true, got %v", got) + } + if got := isInteractiveCheck(nil, false); got { + t.Errorf("nil cmd + non-TTY should be false, got %v", got) + } +} diff --git a/internal/appctx/middleware.go b/internal/appctx/middleware.go new file mode 100644 index 000000000..259c623e0 --- /dev/null +++ b/internal/appctx/middleware.go @@ -0,0 +1,56 @@ +// Package appctx composes command middleware. In M2 the only middleware is +// WithTelemetry; later milestones add WithAppContext / WithEnvContext / +// WithFormat / WithConfirm. Spec §4.3. +package appctx + +import "github.com/spf13/cobra" + +type RunFunc func(cmd *cobra.Command, args []string) error + +type Middleware func(next RunFunc) RunFunc + +type Builder struct { + cmd *cobra.Command + middleware []Middleware +} + +func Build(cmd *cobra.Command, mw ...Middleware) *Builder { + return &Builder{cmd: cmd, middleware: mw} +} + +func (b *Builder) WithRun(base RunFunc) *cobra.Command { + chain := base + for i := len(b.middleware) - 1; i >= 0; i-- { + chain = b.middleware[i](chain) + } + b.cmd.RunE = chain + return b.cmd +} + +// WithRenderableRun finalizes the builder for handlers that return (any, error). +// Use this when the chain includes WithFormat. Pure-error handlers use WithRun. +// +// The base RenderableRunFunc should ALREADY be wrapped with WithFormat (the +// innermost middleware closest to the handler) so output.Render runs against +// the data return. Builder's outer middleware slice receives a RunFunc +// adapter that discards the any return after rendering. +func (b *Builder) WithRenderableRun(base RenderableRunFunc) *cobra.Command { + finalAsRun := RunFunc(func(cmd *cobra.Command, args []string) error { + _, err := base(cmd, args) + return err + }) + return b.WithRun(finalAsRun) +} + +// Chain composes middlewares left-to-right: Chain(a, b)(next) == a(b(next)). +// Useful when a handler needs multiple middlewares but they're not wrapped +// by a Builder. +func Chain(mw ...Middleware) Middleware { + return func(next RunFunc) RunFunc { + chain := next + for i := len(mw) - 1; i >= 0; i-- { + chain = mw[i](chain) + } + return chain + } +} diff --git a/internal/appctx/middleware_test.go b/internal/appctx/middleware_test.go new file mode 100644 index 000000000..40a809b0b --- /dev/null +++ b/internal/appctx/middleware_test.go @@ -0,0 +1,88 @@ +package appctx + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestMiddlewareChainExecutionOrder(t *testing.T) { + var calls []string + mw1 := func(next RunFunc) RunFunc { + return func(cmd *cobra.Command, args []string) error { + calls = append(calls, "mw1-before") + err := next(cmd, args) + calls = append(calls, "mw1-after") + return err + } + } + mw2 := func(next RunFunc) RunFunc { + return func(cmd *cobra.Command, args []string) error { + calls = append(calls, "mw2-before") + err := next(cmd, args) + calls = append(calls, "mw2-after") + return err + } + } + base := func(cmd *cobra.Command, args []string) error { + calls = append(calls, "handler") + return nil + } + cmd := &cobra.Command{Use: "test"} + wrapped := Build(cmd, mw1, mw2).WithRun(base) + if err := wrapped.RunE(wrapped, []string{}); err != nil { + t.Fatalf("RunE: %v", err) + } + want := []string{"mw1-before", "mw2-before", "handler", "mw2-after", "mw1-after"} + if !equalStrings(calls, want) { + t.Errorf("calls = %v, want %v", calls, want) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestChainComposesLeftToRight(t *testing.T) { + var calls []string + a := func(next RunFunc) RunFunc { + return func(cmd *cobra.Command, args []string) error { + calls = append(calls, "a-pre") + err := next(cmd, args) + calls = append(calls, "a-post") + return err + } + } + b := func(next RunFunc) RunFunc { + return func(cmd *cobra.Command, args []string) error { + calls = append(calls, "b-pre") + err := next(cmd, args) + calls = append(calls, "b-post") + return err + } + } + core := func(cmd *cobra.Command, args []string) error { + calls = append(calls, "core") + return nil + } + if err := Chain(a, b)(core)(&cobra.Command{}, nil); err != nil { + t.Fatalf("err: %v", err) + } + want := []string{"a-pre", "b-pre", "core", "b-post", "a-post"} + if len(calls) != len(want) { + t.Fatalf("len(calls) = %d, want %d (calls=%v)", len(calls), len(want), calls) + } + for i := range want { + if calls[i] != want[i] { + t.Errorf("calls[%d] = %q, want %q", i, calls[i], want[i]) + } + } +} diff --git a/internal/appctx/prompts.go b/internal/appctx/prompts.go new file mode 100644 index 000000000..7015e0fe1 --- /dev/null +++ b/internal/appctx/prompts.go @@ -0,0 +1,70 @@ +package appctx + +import ( + "errors" + "fmt" + "io" + "os" + + "github.com/AlecAivazis/survey/v2" + "github.com/spf13/cobra" +) + +// ErrNonInteractive is returned when a prompt is requested in a non-interactive +// context and no fallback is available. Callers typically use errors.Is to +// detect this and convert to a "missing required flag" error. +var ErrNonInteractive = errors.New("non-interactive context: cannot prompt") + +// Confirm asks a yes/no question. Returns ErrNonInteractive when the session +// is non-interactive (caller decides whether to default-deny or fail). +func Confirm(cmd *cobra.Command, message string, defaultYes bool) (bool, error) { + return confirmCore(IsInteractive(cmd), os.Stderr, message, defaultYes) +} + +func confirmCore(interactive bool, stderr io.Writer, message string, defaultYes bool) (bool, error) { + if !interactive { + fmt.Fprintf(stderr, "Cannot prompt in non-interactive mode: %s\n", message) + return false, ErrNonInteractive + } + var out bool + prompt := &survey.Confirm{Message: message, Default: defaultYes} + if err := survey.AskOne(prompt, &out); err != nil { + return false, err + } + return out, nil +} + +// Input asks for a free-form string. If non-interactive and fallback is +// non-empty, returns the fallback; otherwise ErrNonInteractive. +func Input(cmd *cobra.Command, message, fallback string) (string, error) { + if !IsInteractive(cmd) { + if fallback != "" { + return fallback, nil + } + return "", ErrNonInteractive + } + var out string + prompt := &survey.Input{Message: message, Default: fallback} + if err := survey.AskOne(prompt, &out); err != nil { + return "", err + } + return out, nil +} + +// Select offers a list. options[0] is the default. Non-interactive with at +// least one option returns options[0]; non-interactive with no options +// returns ErrNonInteractive. +func Select(cmd *cobra.Command, message string, options []string) (string, error) { + if !IsInteractive(cmd) { + if len(options) > 0 { + return options[0], nil + } + return "", ErrNonInteractive + } + var out string + prompt := &survey.Select{Message: message, Options: options, Default: options[0]} + if err := survey.AskOne(prompt, &out); err != nil { + return "", err + } + return out, nil +} diff --git a/internal/appctx/prompts_test.go b/internal/appctx/prompts_test.go new file mode 100644 index 000000000..49b3ee9e2 --- /dev/null +++ b/internal/appctx/prompts_test.go @@ -0,0 +1,97 @@ +package appctx + +import ( + "bytes" + "errors" + "testing" + + "github.com/spf13/cobra" +) + +// makeNonInteractiveCmd builds a cobra command with --non-interactive=true +// set via PersistentFlags (the bucket the flag was defined on — see Task 6 +// for why this matters in pre-Execute test scenarios). +func makeNonInteractiveCmd(t *testing.T) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "x"} + cmd.PersistentFlags().Bool("non-interactive", false, "") + if err := cmd.PersistentFlags().Set("non-interactive", "true"); err != nil { + t.Fatalf("set --non-interactive: %v", err) + } + return cmd +} + +func TestConfirmNonInteractiveReturnsErr(t *testing.T) { + cmd := makeNonInteractiveCmd(t) + got, err := Confirm(cmd, "delete the world?", false) + if !errors.Is(err, ErrNonInteractive) { + t.Errorf("err = %v, want ErrNonInteractive", err) + } + if got != false { + t.Errorf("got = %v, want false", got) + } +} + +func TestInputNonInteractiveErrorsWithoutDefault(t *testing.T) { + cmd := makeNonInteractiveCmd(t) + got, err := Input(cmd, "value?", "") + if !errors.Is(err, ErrNonInteractive) { + t.Errorf("err = %v, want ErrNonInteractive", err) + } + if got != "" { + t.Errorf("got = %q, want empty", got) + } +} + +func TestInputNonInteractiveAllowsDefault(t *testing.T) { + cmd := makeNonInteractiveCmd(t) + got, err := Input(cmd, "value?", "fallback") + if err != nil { + t.Fatalf("err: %v", err) + } + if got != "fallback" { + t.Errorf("Input = %q, want fallback", got) + } +} + +func TestSelectNonInteractiveReturnsFirst(t *testing.T) { + cmd := makeNonInteractiveCmd(t) + got, err := Select(cmd, "pick:", []string{"a", "b", "c"}) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != "a" { + t.Errorf("Select = %q, want a", got) + } +} + +func TestSelectNonInteractiveEmptyOptionsErr(t *testing.T) { + cmd := makeNonInteractiveCmd(t) + got, err := Select(cmd, "pick:", nil) + if !errors.Is(err, ErrNonInteractive) { + t.Errorf("err = %v, want ErrNonInteractive", err) + } + if got != "" { + t.Errorf("got = %q, want empty", got) + } +} + +// confirmCore must print to the provided writer in non-interactive mode and +// return ErrNonInteractive. This pins the contract that the wrapper warns +// the operator before failing. +func TestConfirmCoreNonInteractiveWritesStderr(t *testing.T) { + var stderr bytes.Buffer + got, err := confirmCore(false /*interactive*/, &stderr, "test message", true) + if got != false { + t.Errorf("got = %v, want false", got) + } + if !errors.Is(err, ErrNonInteractive) { + t.Errorf("err = %v, want ErrNonInteractive", err) + } + if stderr.Len() == 0 { + t.Error("confirmCore non-interactive must write something to stderr") + } + if !bytes.Contains(stderr.Bytes(), []byte("test message")) { + t.Errorf("stderr = %q, want it to include the prompt message", stderr.String()) + } +} diff --git a/internal/appctx/required_args.go b/internal/appctx/required_args.go new file mode 100644 index 000000000..2947fa92f --- /dev/null +++ b/internal/appctx/required_args.go @@ -0,0 +1,24 @@ +package appctx + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// WithRequiredArgs enforces an exact positional-arg count. On mismatch returns +// a Node-parity error: "Please supply N argument(s): ". +func WithRequiredArgs(n int) Middleware { + return func(next RunFunc) RunFunc { + return func(cmd *cobra.Command, args []string) error { + if len(args) != n { + word := "arguments" + if n == 1 { + word = "argument" + } + return fmt.Errorf("Please supply %d %s: %s", n, word, cmd.UseLine()) + } + return next(cmd, args) + } + } +} diff --git a/internal/appctx/required_args_test.go b/internal/appctx/required_args_test.go new file mode 100644 index 000000000..ba7959a83 --- /dev/null +++ b/internal/appctx/required_args_test.go @@ -0,0 +1,38 @@ +package appctx + +import ( + "context" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestWithRequiredArgsAccepts(t *testing.T) { + cmd := &cobra.Command{Use: "get "} + cmd.SetContext(context.Background()) + mw := WithRequiredArgs(1) + run := mw(func(cmd *cobra.Command, args []string) error { + if len(args) != 1 || args[0] != "FOO" { + t.Errorf("args = %v", args) + } + return nil + }) + if err := run(cmd, []string{"FOO"}); err != nil { + t.Fatalf("run: %v", err) + } +} + +func TestWithRequiredArgsRejects(t *testing.T) { + cmd := &cobra.Command{Use: "get "} + cmd.SetContext(context.Background()) + mw := WithRequiredArgs(1) + run := mw(func(cmd *cobra.Command, args []string) error { + t.Error("handler must not run when arg count is wrong") + return nil + }) + err := run(cmd, []string{}) + if err == nil || !strings.Contains(err.Error(), "Please supply 1 argument") { + t.Errorf("err = %v, want Node-parity supply-argument error", err) + } +} diff --git a/internal/appctx/telemetry.go b/internal/appctx/telemetry.go new file mode 100644 index 000000000..29c92ad08 --- /dev/null +++ b/internal/appctx/telemetry.go @@ -0,0 +1,22 @@ +package appctx + +import "github.com/spf13/cobra" + +type CommandTracker interface { + MakeCommandTracker(command string, info map[string]any) func(eventType string, data map[string]any) +} + +func WithTelemetry(tr CommandTracker, command string, info map[string]any) Middleware { + track := tr.MakeCommandTracker(command, info) + return func(next RunFunc) RunFunc { + return func(cmd *cobra.Command, args []string) error { + track("execute", nil) + if err := next(cmd, args); err != nil { + track("error", map[string]any{"error": err.Error()}) + return err + } + track("success", nil) + return nil + } + } +} diff --git a/internal/appctx/telemetry_test.go b/internal/appctx/telemetry_test.go new file mode 100644 index 000000000..e718ffbc2 --- /dev/null +++ b/internal/appctx/telemetry_test.go @@ -0,0 +1,42 @@ +package appctx + +import ( + "errors" + "testing" + + "github.com/spf13/cobra" +) + +type fakeTracker struct { + events []string +} + +func (f *fakeTracker) MakeCommandTracker(cmd string, info map[string]any) func(string, map[string]any) { + return func(eventType string, data map[string]any) { + f.events = append(f.events, cmd+"_"+eventType) + } +} + +func TestWithTelemetryEmitsExecuteAndSuccess(t *testing.T) { + tr := &fakeTracker{} + cmd := &cobra.Command{Use: "demo"} + wrapped := Build(cmd, WithTelemetry(tr, "demo", nil)).WithRun(func(cmd *cobra.Command, args []string) error { return nil }) + if err := wrapped.RunE(wrapped, nil); err != nil { + t.Fatalf("RunE: %v", err) + } + if len(tr.events) != 2 || tr.events[0] != "demo_execute" || tr.events[1] != "demo_success" { + t.Errorf("events = %v", tr.events) + } +} + +func TestWithTelemetryEmitsErrorOnFailure(t *testing.T) { + tr := &fakeTracker{} + cmd := &cobra.Command{Use: "demo"} + wrapped := Build(cmd, WithTelemetry(tr, "demo", nil)).WithRun(func(cmd *cobra.Command, args []string) error { + return errors.New("boom") + }) + wrapped.RunE(wrapped, nil) + if len(tr.events) != 2 || tr.events[1] != "demo_error" { + t.Errorf("events = %v, want [execute, error]", tr.events) + } +} diff --git a/internal/appctx/wildcard.go b/internal/appctx/wildcard.go new file mode 100644 index 000000000..7d38fbd82 --- /dev/null +++ b/internal/appctx/wildcard.go @@ -0,0 +1,29 @@ +package appctx + +import ( + "github.com/spf13/cobra" +) + +// WithWildcardCommand registers a fallback handler on a Cobra parent. When the +// parent is invoked with positional args whose first element is NOT the name +// of a registered subcommand, the fallback runs with those args. Mirrors +// Node's _opts.wildcardCommand pattern. +// +// MUST be called after all real subcommands are added to parent (snapshots +// their names at call time). +func WithWildcardCommand(parent *cobra.Command, fallback RunFunc) { + subNames := map[string]bool{} + for _, c := range parent.Commands() { + subNames[c.Name()] = true + for _, alias := range c.Aliases { + subNames[alias] = true + } + } + parent.Args = cobra.ArbitraryArgs + parent.RunE = func(cmd *cobra.Command, args []string) error { + if len(args) > 0 && subNames[args[0]] { + return cmd.Help() + } + return fallback(cmd, args) + } +} diff --git a/internal/appctx/wildcard_test.go b/internal/appctx/wildcard_test.go new file mode 100644 index 000000000..0f794cf10 --- /dev/null +++ b/internal/appctx/wildcard_test.go @@ -0,0 +1,53 @@ +package appctx + +import ( + "context" + "testing" + + "github.com/spf13/cobra" +) + +func TestWithWildcardCommandRoutesUnknownToFallback(t *testing.T) { + parent := &cobra.Command{Use: "app"} + knownSub := &cobra.Command{Use: "list", RunE: func(cmd *cobra.Command, args []string) error { return nil }} + parent.AddCommand(knownSub) + + var calledWith []string + WithWildcardCommand(parent, func(cmd *cobra.Command, args []string) error { + calledWith = args + return nil + }) + + parent.SetArgs([]string{"example-app"}) + parent.SetContext(context.Background()) + if err := parent.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if len(calledWith) != 1 || calledWith[0] != "example-app" { + t.Errorf("fallback received args=%v, want [example-app]", calledWith) + } +} + +func TestWithWildcardCommandDispatchesKnownSubcommand(t *testing.T) { + parent := &cobra.Command{Use: "app"} + var listCalled bool + knownSub := &cobra.Command{Use: "list", RunE: func(cmd *cobra.Command, args []string) error { + listCalled = true + return nil + }} + parent.AddCommand(knownSub) + + WithWildcardCommand(parent, func(cmd *cobra.Command, args []string) error { + t.Error("fallback must not run when a real subcommand is invoked") + return nil + }) + + parent.SetArgs([]string{"list"}) + parent.SetContext(context.Background()) + if err := parent.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if !listCalled { + t.Error("list subcommand was not dispatched") + } +} From e730eed04dd5d95e046c622059543b49ede7aaa0 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 20 Aug 2026 18:36:40 -0500 Subject: [PATCH 09/32] feat(go): read-only platform APIs Ported from vip-cli-golang@72ef2f89 (feature/go-rewrite). --- internal/cachepurge/cachepurge.go | 38 +++ internal/cachepurge/cachepurge_test.go | 92 +++++ internal/defensivemode/api.go | 89 +++++ internal/defensivemode/api_test.go | 122 +++++++ internal/envalias/envalias.go | 50 +++ internal/envalias/envalias_test.go | 147 ++++++++ internal/envvar/envvar.go | 253 ++++++++++++++ internal/envvar/envvar_test.go | 274 +++++++++++++++ internal/envvar/reload_manifest.go | 99 ++++++ internal/envvar/reload_manifest_test.go | 44 +++ internal/envvar/value_confirm.go | 26 ++ internal/envvar/value_confirm_test.go | 22 ++ internal/logsapi/logsapi.go | 165 +++++++++ internal/logsapi/logsapi_test.go | 82 +++++ internal/phpmyadmin/client.go | 221 ++++++++++++ internal/phpmyadmin/client_test.go | 418 +++++++++++++++++++++++ internal/slowlogsapi/slowlogsapi.go | 176 ++++++++++ internal/slowlogsapi/slowlogsapi_test.go | 93 +++++ internal/softwaresettings/format.go | 192 +++++++++++ internal/softwaresettings/format_test.go | 123 +++++++ 20 files changed, 2726 insertions(+) create mode 100644 internal/cachepurge/cachepurge.go create mode 100644 internal/cachepurge/cachepurge_test.go create mode 100644 internal/defensivemode/api.go create mode 100644 internal/defensivemode/api_test.go create mode 100644 internal/envalias/envalias.go create mode 100644 internal/envalias/envalias_test.go create mode 100644 internal/envvar/envvar.go create mode 100644 internal/envvar/envvar_test.go create mode 100644 internal/envvar/reload_manifest.go create mode 100644 internal/envvar/reload_manifest_test.go create mode 100644 internal/envvar/value_confirm.go create mode 100644 internal/envvar/value_confirm_test.go create mode 100644 internal/logsapi/logsapi.go create mode 100644 internal/logsapi/logsapi_test.go create mode 100644 internal/phpmyadmin/client.go create mode 100644 internal/phpmyadmin/client_test.go create mode 100644 internal/slowlogsapi/slowlogsapi.go create mode 100644 internal/slowlogsapi/slowlogsapi_test.go create mode 100644 internal/softwaresettings/format.go create mode 100644 internal/softwaresettings/format_test.go diff --git a/internal/cachepurge/cachepurge.go b/internal/cachepurge/cachepurge.go new file mode 100644 index 000000000..b14bb881b --- /dev/null +++ b/internal/cachepurge/cachepurge.go @@ -0,0 +1,38 @@ +// Package cachepurge wraps the PurgePageCache mutation. +// +// Node parity: src/lib/api/cache-purge.ts. The server canonicalizes the +// supplied URLs (e.g. host-normalization) and returns the canonical list on +// the response payload, so callers should use the returned slice for any +// downstream "Purged URL: ..." output rather than echoing the input. +package cachepurge + +import ( + "context" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/gql" +) + +// Purge invokes the purgePageCache mutation against the given environment +// and returns the server-canonicalized URL list. The returned slice MAY +// differ from urls (server normalizes hosts/casing); callers should rely +// on it when echoing results to the user. +func Purge(ctx context.Context, c graphql.Client, appID, envID int64, urls []string) ([]string, error) { + input := &gql.PurgePageCacheInput{ + AppId: appID, + EnvironmentId: envID, + Urls: urls, + } + resp, err := gql.PurgePageCache(ctx, c, input) + if err != nil { + return nil, err + } + if resp == nil || resp.PurgePageCache == nil { + // Defensive: schema marks PurgePageCachePayload non-null, but a + // pathological server response could omit it. Return empty so the + // caller prints nothing rather than panicking. + return nil, nil + } + return resp.PurgePageCache.Urls, nil +} diff --git a/internal/cachepurge/cachepurge_test.go b/internal/cachepurge/cachepurge_test.go new file mode 100644 index 000000000..102cc1f44 --- /dev/null +++ b/internal/cachepurge/cachepurge_test.go @@ -0,0 +1,92 @@ +package cachepurge + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Khan/genqlient/graphql" +) + +// TestPurgeSendsMutationAndReturnsCanonicalURLs verifies (a) the wire +// request carries the PurgePageCache operation + the expected input shape +// and (b) the function returns the server-canonicalized URL slice rather +// than echoing the input. +func TestPurgeSendsMutationAndReturnsCanonicalURLs(t *testing.T) { + var lastBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + lastBody = string(b) + w.Header().Set("Content-Type", "application/json") + // Return DIFFERENT URLs than the input — server canonicalization. + _, _ = w.Write([]byte(`{"data":{"purgePageCache":{"success":true,"urls":["https://canonical.example.com/a","https://canonical.example.com/b"]}}}`)) + })) + defer srv.Close() + + c := graphql.NewClient(srv.URL, srv.Client()) + in := []string{"https://example.com/a", "https://example.com/b"} + out, err := Purge(context.Background(), c, 42, 7, in) + if err != nil { + t.Fatalf("Purge: %v", err) + } + + if len(out) != 2 || out[0] != "https://canonical.example.com/a" || out[1] != "https://canonical.example.com/b" { + t.Errorf("Purge returned %v, want canonical server URLs", out) + } + + if !strings.Contains(lastBody, `"operationName":"PurgePageCache"`) { + t.Errorf("request must use PurgePageCache op; body=%s", lastBody) + } + if !strings.Contains(lastBody, `"appId":42`) { + t.Errorf("input.appId missing; body=%s", lastBody) + } + if !strings.Contains(lastBody, `"environmentId":7`) { + t.Errorf("input.environmentId missing; body=%s", lastBody) + } + if !strings.Contains(lastBody, `"urls":["https://example.com/a","https://example.com/b"]`) { + t.Errorf("input.urls missing or wrong shape; body=%s", lastBody) + } +} + +// TestPurgeNilPayloadReturnsEmpty pins the nil-guard in Purge: a server +// response of {"data":{"purgePageCache":null}} must produce (nil, nil) +// instead of panicking on the .Urls dereference. Prevents the guard from +// being silently dropped by a future "simplification". +func TestPurgeNilPayloadReturnsEmpty(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"purgePageCache":null}}`)) + })) + defer srv.Close() + + c := graphql.NewClient(srv.URL, srv.Client()) + out, err := Purge(context.Background(), c, 1, 2, []string{"https://example.com/"}) + if err != nil { + t.Fatalf("unexpected error on null payload: %v", err) + } + if out != nil { + t.Errorf("expected nil slice for null payload; got %v", out) + } +} + +// TestPurgeServerError propagates the underlying GraphQL error so the +// command handler can wrap it with the "Failed to purge URL(s)..." prefix. +func TestPurgeServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"errors":[{"message":"boom"}]}`)) + })) + defer srv.Close() + + c := graphql.NewClient(srv.URL, srv.Client()) + out, err := Purge(context.Background(), c, 1, 2, []string{"https://example.com/"}) + if err == nil { + t.Fatalf("expected error from server; got out=%v", out) + } + if !strings.Contains(err.Error(), "boom") { + t.Errorf("error must propagate server message; got %q", err.Error()) + } +} diff --git a/internal/defensivemode/api.go b/internal/defensivemode/api.go new file mode 100644 index 000000000..d930a00bc --- /dev/null +++ b/internal/defensivemode/api.go @@ -0,0 +1,89 @@ +// Package defensivemode is the M3+M4 test surface for the rechallenge +// middleware. Two GraphQL mutations: UpdateDefensiveModeStatus and +// UpdateDefensiveModeConfig. Both require step-up auth on production +// environments — the rechallenge middleware in internal/gql handles that +// transparently. M4: ports the raw HTTP POST to genqlient. +package defensivemode + +import ( + "context" + "fmt" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/gql" +) + +// UpdateStatusInput is the Go side of AppEnvironmentDefensiveModeUpdateStatusInput. +// The Node wire format uses `id`/`environmentId`/`enabled` (NOT appId/envId); +// genqlient handles the on-the-wire field names from the schema. +type UpdateStatusInput struct { + AppID int64 + EnvID int64 + Enabled bool +} + +type UpdateConfigInput struct { + AppID int64 + EnvID int64 + Enabled bool + ChallengeType int + ConnectionThresholdAbsolute *int + ConnectionThresholdPercentage *int +} + +type MutationResult struct { + Success bool + Message string +} + +func UpdateDefensiveModeStatus(ctx context.Context, client graphql.Client, in UpdateStatusInput) (*MutationResult, error) { + input := &gql.AppEnvironmentDefensiveModeUpdateStatusInput{ + Enabled: in.Enabled, + EnvironmentId: in.EnvID, + Id: in.AppID, + } + resp, err := gql.UpdateDefensiveModeStatus(ctx, client, input) + if err != nil { + return nil, err + } + if resp == nil || resp.UpdateDefensiveModeStatus == nil { + return nil, errMissingPayload("updateDefensiveModeStatus") + } + return &MutationResult{ + Success: resp.UpdateDefensiveModeStatus.Success, + Message: resp.UpdateDefensiveModeStatus.Message, + }, nil +} + +func UpdateDefensiveModeConfig(ctx context.Context, client graphql.Client, in UpdateConfigInput) (*MutationResult, error) { + input := &gql.AppEnvironmentDefensiveModeConfigInput{ + Enabled: in.Enabled, + EnvironmentId: in.EnvID, + Id: in.AppID, + ChallengeType: int64(in.ChallengeType), + } + if in.ConnectionThresholdAbsolute != nil { + v := int64(*in.ConnectionThresholdAbsolute) + input.ConnectionThresholdAbsolute = &v + } + if in.ConnectionThresholdPercentage != nil { + v := int64(*in.ConnectionThresholdPercentage) + input.ConnectionThresholdPercentage = &v + } + resp, err := gql.UpdateDefensiveModeConfig(ctx, client, input) + if err != nil { + return nil, err + } + if resp == nil || resp.UpdateDefensiveModeConfig == nil { + return nil, errMissingPayload("updateDefensiveModeConfig") + } + return &MutationResult{ + Success: resp.UpdateDefensiveModeConfig.Success, + Message: resp.UpdateDefensiveModeConfig.Message, + }, nil +} + +func errMissingPayload(field string) error { + return fmt.Errorf("%s response missing payload; the API may have rejected the request", field) +} diff --git a/internal/defensivemode/api_test.go b/internal/defensivemode/api_test.go new file mode 100644 index 000000000..ca338768e --- /dev/null +++ b/internal/defensivemode/api_test.go @@ -0,0 +1,122 @@ +package defensivemode + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Khan/genqlient/graphql" +) + +func newGQLClient(srv *httptest.Server) graphql.Client { + return graphql.NewClient(srv.URL+"/graphql", srv.Client()) +} + +func TestUpdateDefensiveModeStatusBuildsCorrectRequest(t *testing.T) { + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"updateDefensiveModeStatus":{"success":true,"message":"ok"}}}`)) + })) + defer srv.Close() + c := newGQLClient(srv) + result, err := UpdateDefensiveModeStatus(context.Background(), c, UpdateStatusInput{ + AppID: 42, + EnvID: 7, + Enabled: true, + }) + if err != nil { + t.Fatalf("UpdateDefensiveModeStatus: %v", err) + } + if !result.Success || result.Message != "ok" { + t.Errorf("result = %+v", result) + } + if !strings.Contains(gotBody, `"operationName":"UpdateDefensiveModeStatus"`) { + t.Errorf("operationName missing: %s", gotBody) + } + // Verify the wire shape uses id / environmentId / enabled keys. + for _, want := range []string{`"id":42`, `"environmentId":7`, `"enabled":true`} { + if !strings.Contains(gotBody, want) { + t.Errorf("expected %q in body; got %s", want, gotBody) + } + } +} + +func TestUpdateDefensiveModeConfigOmitsUnsetThresholds(t *testing.T) { + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + _, _ = w.Write([]byte(`{"data":{"updateDefensiveModeConfig":{"success":true,"message":"ok"}}}`)) + })) + defer srv.Close() + c := newGQLClient(srv) + _, err := UpdateDefensiveModeConfig(context.Background(), c, UpdateConfigInput{ + AppID: 42, + EnvID: 7, + Enabled: true, + ChallengeType: 1, + }) + if err != nil { + t.Fatalf("UpdateDefensiveModeConfig: %v", err) + } + // Optional thresholds: when nil on the Go side, they should serialize as + // null on the wire (genqlient pointer optionals are encoded that way), + // not omitted entirely. The schema accepts null for these fields. + // The important property: don't send a NON-null integer for a threshold + // the user didn't set. Look for the substring "5000" / "80" which would + // indicate a leaked value. + if strings.Contains(gotBody, "5000") { + t.Errorf("unset absolute threshold leaked: %s", gotBody) + } + if strings.Contains(gotBody, ",80,") || strings.Contains(gotBody, ":80}") { + t.Errorf("unset percentage threshold leaked: %s", gotBody) + } +} + +func TestUpdateDefensiveModeConfigIncludesSetThresholds(t *testing.T) { + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + _, _ = w.Write([]byte(`{"data":{"updateDefensiveModeConfig":{"success":true,"message":"ok"}}}`)) + })) + defer srv.Close() + c := newGQLClient(srv) + abs := 5000 + pct := 80 + _, err := UpdateDefensiveModeConfig(context.Background(), c, UpdateConfigInput{ + AppID: 42, + EnvID: 7, + Enabled: true, + ChallengeType: 2, + ConnectionThresholdAbsolute: &abs, + ConnectionThresholdPercentage: &pct, + }) + if err != nil { + t.Fatalf("UpdateDefensiveModeConfig: %v", err) + } + if !strings.Contains(gotBody, `"connectionThresholdAbsolute":5000`) { + t.Errorf("absolute threshold missing: %s", gotBody) + } + if !strings.Contains(gotBody, `"connectionThresholdPercentage":80`) { + t.Errorf("percentage threshold missing: %s", gotBody) + } +} + +func TestUpdateDefensiveModeReturnsErrorWhenNoPayload(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"updateDefensiveModeStatus":null}}`)) + })) + defer srv.Close() + c := newGQLClient(srv) + _, err := UpdateDefensiveModeStatus(context.Background(), c, UpdateStatusInput{AppID: 1, EnvID: 1, Enabled: true}) + if err == nil { + t.Error("expected error when payload is null") + } +} diff --git a/internal/envalias/envalias.go b/internal/envalias/envalias.go new file mode 100644 index 000000000..716ce7761 --- /dev/null +++ b/internal/envalias/envalias.go @@ -0,0 +1,50 @@ +// Package envalias implements the @app.env pre-parser. +// +// The function Rewrite walks argv left-to-right, stops at the first "--", +// strips the FIRST @app[.env[.instance...]] token, and returns the +// rewritten argv plus the extracted (lowercased) app and env. A second +// alias-shaped token is left in place. Tokens that begin with "@" but +// do not match the alias regex pass through unchanged (Node behavior). +// +// Behavior matches the Node implementation in src/lib/cli/envAlias.ts. +package envalias + +import ( + "regexp" + "strings" +) + +// aliasRE matches the full Node isAlias pattern. +var aliasRE = regexp.MustCompile(`^@[A-Za-z0-9._-]+$`) + +func Rewrite(argv []string) (rewritten []string, app, env string, err error) { + rewritten = make([]string, 0, len(argv)) + consumed := false + + for i, tok := range argv { + if tok == "--" { + rewritten = append(rewritten, argv[i:]...) + return rewritten, app, env, nil + } + if !consumed && aliasRE.MatchString(tok) { + app, env = parseAlias(tok) + consumed = true + continue + } + rewritten = append(rewritten, tok) + } + return rewritten, app, env, nil +} + +// parseAlias strips "@", lowercases the remainder, splits on the first ".". +// The first segment is the app; the rest (joined on ".") is the env. +// Mirrors src/lib/cli/envAlias.ts:parseEnvAlias. +func parseAlias(tok string) (app, env string) { + stripped := strings.ToLower(tok[1:]) + parts := strings.SplitN(stripped, ".", 2) + app = parts[0] + if len(parts) == 2 { + env = parts[1] + } + return app, env +} diff --git a/internal/envalias/envalias_test.go b/internal/envalias/envalias_test.go new file mode 100644 index 000000000..9977ed4e6 --- /dev/null +++ b/internal/envalias/envalias_test.go @@ -0,0 +1,147 @@ +package envalias + +import ( + "reflect" + "testing" +) + +func TestRewrite(t *testing.T) { + tests := []struct { + name string + argv []string + wantArgv []string + wantApp string + wantEnv string + }{ + { + name: "no alias passes through", + argv: []string{"app", "list"}, + wantArgv: []string{"app", "list"}, + }, + { + name: "alias at position 0, app only", + argv: []string{"@my-app", "app", "list"}, + wantArgv: []string{"app", "list"}, + wantApp: "my-app", + }, + { + name: "alias at position 0, app and env", + argv: []string{"@my-app.staging", "app", "list"}, + wantArgv: []string{"app", "list"}, + wantApp: "my-app", + wantEnv: "staging", + }, + { + name: "alias after subcommand", + argv: []string{"app", "list", "@my-app.staging"}, + wantArgv: []string{"app", "list"}, + wantApp: "my-app", + wantEnv: "staging", + }, + { + name: "alias trailing after flags", + argv: []string{"something", "--argument=value", "@my-app"}, + wantArgv: []string{"something", "--argument=value"}, + wantApp: "my-app", + }, + { + name: "alias between subcommand and flag", + argv: []string{"app", "@my-app.staging", "--debug"}, + wantArgv: []string{"app", "--debug"}, + wantApp: "my-app", + wantEnv: "staging", + }, + { + name: "token after `--` is not parsed", + argv: []string{"wp", "--", "@plugin", "activate"}, + wantArgv: []string{"wp", "--", "@plugin", "activate"}, + }, + { + name: "alias before `--` is parsed, tokens after are preserved", + argv: []string{"@my-app", "wp", "--", "@plugin", "activate"}, + wantArgv: []string{"wp", "--", "@plugin", "activate"}, + wantApp: "my-app", + }, + { + name: "mixed case is lowercased (Node parity)", + argv: []string{"@MyApp.Prod", "app", "list"}, + wantArgv: []string{"app", "list"}, + wantApp: "myapp", + wantEnv: "prod", + }, + { + name: "instance-qualified env: three dotted segments", + argv: []string{"@app.env.instance", "wp"}, + wantArgv: []string{"wp"}, + wantApp: "app", + wantEnv: "env.instance", + }, + { + name: "underscore in env name", + argv: []string{"@xxx.production_test", "wp"}, + wantArgv: []string{"wp"}, + wantApp: "xxx", + wantEnv: "production_test", + }, + { + name: "numeric app slug", + argv: []string{"@1.env", "wp"}, + wantArgv: []string{"wp"}, + wantApp: "1", + wantEnv: "env", + }, + { + name: "first alias consumed, second remains (Node parity)", + argv: []string{"@a", "app", "@b"}, + wantArgv: []string{"app", "@b"}, + wantApp: "a", + }, + { + name: "bare @ passes through (does not match isAlias)", + argv: []string{"@", "app"}, + wantArgv: []string{"@", "app"}, + }, + { + name: "@app. matches isAlias and parses with empty env", + argv: []string{"@app.", "list"}, + wantArgv: []string{"list"}, + wantApp: "app", + wantEnv: "", + }, + { + name: "@.env matches isAlias and parses with empty app", + argv: []string{"@.env", "list"}, + wantArgv: []string{"list"}, + wantApp: "", + wantEnv: "env", + }, + { + name: "empty argv", + argv: []string{}, + wantArgv: []string{}, + }, + { + name: "only --", + argv: []string{"--"}, + wantArgv: []string{"--"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotArgv, gotApp, gotEnv, err := Rewrite(tc.argv) + if err != nil { + t.Fatalf("Rewrite() unexpected err = %v", err) + } + if !reflect.DeepEqual(gotArgv, tc.wantArgv) { + t.Errorf("argv = %v, want %v", gotArgv, tc.wantArgv) + } + if gotApp != tc.wantApp { + t.Errorf("app = %q, want %q", gotApp, tc.wantApp) + } + if gotEnv != tc.wantEnv { + t.Errorf("env = %q, want %q", gotEnv, tc.wantEnv) + } + }) + } +} diff --git a/internal/envvar/envvar.go b/internal/envvar/envvar.go new file mode 100644 index 000000000..e2e4b17cc --- /dev/null +++ b/internal/envvar/envvar.go @@ -0,0 +1,253 @@ +// Package envvar wraps the GetEnvironmentVariables and +// GetEnvironmentVariablesWithValues genqlient operations behind a stable +// Go-friendly surface. +// +// The schema exposes only two operations — list names and list with values — +// so there is no server-side single-name fetch. Node's `vip config envvar get +// ` filters client-side from the get-all result; we mirror that. +// +// The two operations have distinct genqlient response types (different +// concrete types per query), so we walk both via reflection to a single +// flat slice. See Node parity sources: src/lib/envvar/api-list.ts, +// api-get.ts, api-get-all.ts. +package envvar + +import ( + "context" + "errors" + "fmt" + "os" + "reflect" + "regexp" + "strings" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/gql" +) + +// NewRelicKey is the protected variable name. Node parity: +// src/bin/vip-config-envvar-set.js refuses to set it because the platform +// owns the value. Compared against the uppercased name (the handler +// uppercases before this check, mirroring Node). +const NewRelicKey = "NEW_RELIC_LICENSE_KEY" + +// validNameRe matches Node's effective `validateName` regex from +// src/lib/envvar/api.ts: trim+uppercase+strip non-[A-Z0-9_], then require +// the original to round-trip AND start with [A-Z]. Underscore-leading +// names are rejected (e.g. "_FOO") — Node parity. +var validNameRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) + +// ErrInvalidName is the user-facing error returned by ValidateName. +// The message text matches Node's (modulo color) so parity scenarios can +// assert against substrings. +var ErrInvalidName = errors.New("Environment variable name must consist of A-Z, 0-9, or _,\nand must start with an uppercase letter.") + +// ValidateName returns nil if name matches Node's validation, otherwise +// ErrInvalidName. Empty is a special-case "name cannot be empty" error. +// Callers should uppercase + trim BEFORE calling — this function does not +// normalize on its own (matches Node where the uppercase is done in the +// command handler, then validateName runs against the result). +func ValidateName(name string) error { + if name == "" { + return errors.New("Environment variable name cannot be empty") + } + if !validNameRe.MatchString(name) { + return ErrInvalidName + } + return nil +} + +// Set adds or updates an environment variable. Node parity: api-set.ts +// calls ONLY addEnvironmentVariable — the server does upsert internally. +// We do the same. reloadManifest=false in current parity scenarios; the +// follow-up prompt is deferred (see config_envvar_set.go scope note). +func Set(ctx context.Context, c graphql.Client, appID, envID int64, name, value string, reloadManifest bool) error { + input := &gql.EnvironmentVariableInput{ + ApplicationId: appID, + EnvironmentId: envID, + Name: name, + Value: value, + ReloadManifest: &reloadManifest, + } + _, err := gql.AddEnvironmentVariable(ctx, c, input) + return err +} + +// Delete removes an environment variable. Node parity: api-delete.ts sends +// value: "" (empty string, NOT omitted) on the input — the schema marks +// Value as required even on delete. +func Delete(ctx context.Context, c graphql.Client, appID, envID int64, name string, reloadManifest bool) error { + input := &gql.EnvironmentVariableInput{ + ApplicationId: appID, + EnvironmentId: envID, + Name: name, + Value: "", + ReloadManifest: &reloadManifest, + } + _, err := gql.DeleteEnvironmentVariable(ctx, c, input) + return err +} + +// ReadFromFile reads the file at path and returns its content with +// leading + trailing whitespace stripped (Node parity: src/lib/read-file.ts +// does `data.trim()` — full TrimSpace, NOT just TrimRight). Internal +// whitespace is preserved. +func ReadFromFile(path string) (string, error) { + b, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read %s: %w", path, err) + } + return strings.TrimSpace(string(b)), nil +} + +// EnvVar is a flat name/value pair. Empty Value distinguishes the +// list-of-names path from the with-values path at the type level. +type EnvVar struct { + Name string + Value string +} + +// List returns the names (only) of environment variables on the env. Empty +// slice is a valid result (no env vars set). Network/schema errors propagate. +func List(ctx context.Context, c graphql.Client, appID, envID int64) ([]string, error) { + resp, err := gql.GetEnvironmentVariables(ctx, c, appID, envID) + if err != nil { + return nil, err + } + nodes := walkEnvVarNodes(resp) + out := make([]string, 0, len(nodes)) + for _, n := range nodes { + out = append(out, n.Name) + } + return out, nil +} + +// Get returns the EnvVar matching name, or nil if not present. Implements +// single-fetch client-side (the schema has no per-name query). Node parity: +// src/lib/envvar/api-get.ts. +func Get(ctx context.Context, c graphql.Client, appID, envID int64, name string) (*EnvVar, error) { + vars, err := GetAll(ctx, c, appID, envID) + if err != nil { + return nil, err + } + for i := range vars { + if vars[i].Name == name { + return &vars[i], nil + } + } + return nil, nil +} + +// GetAll returns every environment variable with its value. +func GetAll(ctx context.Context, c graphql.Client, appID, envID int64) ([]EnvVar, error) { + resp, err := gql.GetEnvironmentVariablesWithValues(ctx, c, appID, envID) + if err != nil { + return nil, err + } + nodes := walkEnvVarNodes(resp) + out := make([]EnvVar, 0, len(nodes)) + for _, n := range nodes { + out = append(out, EnvVar{Name: n.Name, Value: n.Value}) + } + return out, nil +} + +// envVarNode is a flat per-node view extracted via reflection. +type envVarNode struct { + Name string + Value string +} + +// walkEnvVarNodes accepts either GetEnvironmentVariablesResponse or +// GetEnvironmentVariablesWithValuesResponse (distinct genqlient concrete +// types) and yields a flat slice. The Value field is empty when the +// underlying response omits it (list-of-names query). +func walkEnvVarNodes(v any) []envVarNode { + out := []envVarNode{} + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Ptr { + if rv.IsNil() { + return out + } + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return out + } + // Navigate: resp.App -> envs[0] -> EnvironmentVariables -> Nodes + app := rv.FieldByName("App") + for app.Kind() == reflect.Ptr { + if app.IsNil() { + return out + } + app = app.Elem() + } + if !app.IsValid() || app.Kind() != reflect.Struct { + return out + } + envs := app.FieldByName("Environments") + if !envs.IsValid() || envs.Kind() != reflect.Slice || envs.Len() == 0 { + return out + } + env := envs.Index(0) + for env.Kind() == reflect.Ptr { + if env.IsNil() { + return out + } + env = env.Elem() + } + if env.Kind() != reflect.Struct { + return out + } + ev := env.FieldByName("EnvironmentVariables") + for ev.Kind() == reflect.Ptr { + if ev.IsNil() { + return out + } + ev = ev.Elem() + } + if !ev.IsValid() || ev.Kind() != reflect.Struct { + return out + } + nodes := ev.FieldByName("Nodes") + if !nodes.IsValid() || nodes.Kind() != reflect.Slice { + return out + } + for i := 0; i < nodes.Len(); i++ { + n := nodes.Index(i) + for n.Kind() == reflect.Ptr { + if n.IsNil() { + n = reflect.Value{} + break + } + n = n.Elem() + } + if !n.IsValid() || n.Kind() != reflect.Struct { + continue + } + var item envVarNode + if f := n.FieldByName("Name"); f.IsValid() { + switch f.Kind() { + case reflect.Ptr: + if !f.IsNil() { + item.Name = f.Elem().String() + } + case reflect.String: + item.Name = f.String() + } + } + if f := n.FieldByName("Value"); f.IsValid() { + switch f.Kind() { + case reflect.Ptr: + if !f.IsNil() { + item.Value = f.Elem().String() + } + case reflect.String: + item.Value = f.String() + } + } + out = append(out, item) + } + return out +} diff --git a/internal/envvar/envvar_test.go b/internal/envvar/envvar_test.go new file mode 100644 index 000000000..6190f906d --- /dev/null +++ b/internal/envvar/envvar_test.go @@ -0,0 +1,274 @@ +package envvar + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/Khan/genqlient/graphql" +) + +// envvarServer returns a stub /graphql endpoint that responds with the given +// JSON body for every request. Sufficient for these tests because we drive +// each genqlient call in isolation. +func envvarServer(body string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) +} + +func TestEnvvarListReturnsNames(t *testing.T) { + srv := envvarServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":2,"nodes":[{"name":"FOO"},{"name":"BAR"}]}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + names, err := List(context.Background(), c, 1, 2) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(names) != 2 || names[0] != "FOO" || names[1] != "BAR" { + t.Errorf("names = %v, want [FOO BAR]", names) + } +} + +func TestEnvvarListEmpty(t *testing.T) { + srv := envvarServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":0,"nodes":[]}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + names, err := List(context.Background(), c, 1, 2) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(names) != 0 { + t.Errorf("names = %v, want empty", names) + } +} + +func TestEnvvarGetFound(t *testing.T) { + srv := envvarServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":2,"nodes":[{"name":"FOO","value":"1"},{"name":"BAR","value":"two"}]}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + ev, err := Get(context.Background(), c, 1, 2, "BAR") + if err != nil { + t.Fatalf("Get: %v", err) + } + if ev == nil || ev.Name != "BAR" || ev.Value != "two" { + t.Errorf("Get(BAR) = %+v, want {Name:BAR Value:two}", ev) + } +} + +func TestEnvvarGetNotFound(t *testing.T) { + srv := envvarServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":1,"nodes":[{"name":"FOO","value":"1"}]}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + ev, err := Get(context.Background(), c, 1, 2, "MISSING") + if err != nil { + t.Fatalf("Get(MISSING) error: %v", err) + } + if ev != nil { + t.Errorf("Get(MISSING) = %+v, want nil", ev) + } +} + +func TestEnvvarGetAllReturnsValues(t *testing.T) { + srv := envvarServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":2,"nodes":[{"name":"A","value":"1"},{"name":"B","value":"two"}]}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + vars, err := GetAll(context.Background(), c, 1, 2) + if err != nil { + t.Fatalf("GetAll: %v", err) + } + if len(vars) != 2 { + t.Fatalf("vars len = %d, want 2; got=%+v", len(vars), vars) + } + if vars[0].Name != "A" || vars[0].Value != "1" { + t.Errorf("vars[0] = %+v, want {A 1}", vars[0]) + } + if vars[1].Name != "B" || vars[1].Value != "two" { + t.Errorf("vars[1] = %+v, want {B two}", vars[1]) + } +} + +func TestEnvvarGetAllNullValueIsEmptyString(t *testing.T) { + // Schema declares value as nullable: `value: String`. A null value should + // surface as an empty Go string rather than panic on a nil pointer. + srv := envvarServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"environmentVariables":{"total":1,"nodes":[{"name":"NULLY","value":null}]}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + vars, err := GetAll(context.Background(), c, 1, 2) + if err != nil { + t.Fatalf("GetAll: %v", err) + } + if len(vars) != 1 || vars[0].Name != "NULLY" || vars[0].Value != "" { + t.Errorf("vars = %+v, want one {NULLY ''}", vars) + } +} + +// recordingServer is a multi-route stub that records the last request body. +// Used by Set / Delete tests to assert the wire-level mutation shape. +type recordingServer struct { + mu sync.Mutex + lastBody string + respBody string +} + +func (s *recordingServer) start() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s.mu.Lock() + s.lastBody = string(body) + s.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(s.respBody)) + })) +} + +func (s *recordingServer) body() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastBody +} + +func TestValidateName(t *testing.T) { + cases := []struct { + name string + wantErr bool + }{ + {"FOO", false}, + {"FOO_BAR", false}, + {"FOO123", false}, + {"F", false}, + {"A1_B2", false}, + // Empty: distinct error message. + {"", true}, + // Lowercase rejected. + {"foo", true}, + {"Foo", true}, + // Underscore-start rejected (Node parity). + {"_FOO", true}, + // Digit-start rejected. + {"1FOO", true}, + // Dash rejected. + {"FOO-BAR", true}, + // Space rejected. + {"FOO BAR", true}, + // Dot rejected. + {"FOO.BAR", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateName(tc.name) + if tc.wantErr && err == nil { + t.Errorf("ValidateName(%q) = nil, want error", tc.name) + } + if !tc.wantErr && err != nil { + t.Errorf("ValidateName(%q) = %v, want nil", tc.name, err) + } + }) + } +} + +func TestValidateNameInvalidErrorMessage(t *testing.T) { + err := ValidateName("bad-name") + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, ErrInvalidName) { + t.Errorf("invalid-name path must return ErrInvalidName sentinel; got %v", err) + } + if !strings.Contains(err.Error(), "A-Z, 0-9, or _") { + t.Errorf("error message must include Node-parity hint; got %q", err.Error()) + } +} + +func TestSetSendsAddMutation(t *testing.T) { + rs := &recordingServer{ + respBody: `{"data":{"addEnvironmentVariable":{"environmentVariables":{"total":1,"nodes":[{"name":"FOO"}]}}}}`, + } + srv := rs.start() + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + if err := Set(context.Background(), c, 42, 7, "FOO", "hello", false); err != nil { + t.Fatalf("Set: %v", err) + } + body := rs.body() + if !strings.Contains(body, `"operationName":"AddEnvironmentVariable"`) { + t.Errorf("expected AddEnvironmentVariable operation; body=%s", body) + } + if !strings.Contains(body, `"name":"FOO"`) { + t.Errorf("expected name=FOO in input; body=%s", body) + } + if !strings.Contains(body, `"value":"hello"`) { + t.Errorf("expected value=hello in input; body=%s", body) + } + if !strings.Contains(body, `"applicationId":42`) { + t.Errorf("expected applicationId=42; body=%s", body) + } + if !strings.Contains(body, `"environmentId":7`) { + t.Errorf("expected environmentId=7; body=%s", body) + } +} + +func TestDeleteSendsDeleteMutationWithEmptyValue(t *testing.T) { + rs := &recordingServer{ + respBody: `{"data":{"deleteEnvironmentVariable":{"environmentVariables":{"total":0,"nodes":[]}}}}`, + } + srv := rs.start() + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + if err := Delete(context.Background(), c, 42, 7, "FOO", false); err != nil { + t.Fatalf("Delete: %v", err) + } + body := rs.body() + if !strings.Contains(body, `"operationName":"DeleteEnvironmentVariable"`) { + t.Errorf("expected DeleteEnvironmentVariable operation; body=%s", body) + } + // Node parity: delete sends value: "" — empty string, NOT omitted. + if !strings.Contains(body, `"value":""`) { + t.Errorf("delete must send empty-string value; body=%s", body) + } + if !strings.Contains(body, `"name":"FOO"`) { + t.Errorf("expected name=FOO in input; body=%s", body) + } +} + +func TestReadFromFileTrimsSurroundingWhitespace(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "value.txt") + // Surrounding whitespace + a newline that Node's data.trim() would strip, + // and an internal newline that must survive. + content := "\n hello\nworld\n \n" + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("write tmp file: %v", err) + } + got, err := ReadFromFile(path) + if err != nil { + t.Fatalf("ReadFromFile: %v", err) + } + want := "hello\nworld" + if got != want { + t.Errorf("ReadFromFile = %q, want %q", got, want) + } +} + +func TestReadFromFileMissing(t *testing.T) { + _, err := ReadFromFile(filepath.Join(t.TempDir(), "does-not-exist")) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} diff --git a/internal/envvar/reload_manifest.go b/internal/envvar/reload_manifest.go new file mode 100644 index 000000000..475d6a236 --- /dev/null +++ b/internal/envvar/reload_manifest.go @@ -0,0 +1,99 @@ +// Package envvar — reload-manifest UX helpers. +// +// These mirror Node's src/lib/envvar/input.ts surface: +// +// - promptForReloadManifest(appTypeId): yes/no Confirm asking whether to +// apply the envvar update now; prints a Node.js-specific build-vs-runtime +// warning for typeIds {3, 5, 7, 8}. +// - showDeployWarning(): yellow-bg "Important:" reminder printed after the +// mutation when the user declined the reload (or didn't get prompted). +// +// Node parity callers: src/bin/vip-config-envvar-set.js and +// src/bin/vip-config-envvar-delete.js wire these into the success path. +package envvar + +import ( + "fmt" + "io" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/Automattic/vip/internal/appctx" +) + +// NodeJsTypeIds mirrors src/lib/constants/vipgo.ts NODEJS_SITE_TYPE_IDS. +// Used to gate the Node.js-specific build-vs-runtime envvar warning. +var NodeJsTypeIds = map[int64]struct{}{3: {}, 5: {}, 7: {}, 8: {}} + +// isAppNodejs reports whether typeId belongs to NODEJS_SITE_TYPE_IDS. +// typeId == 0 (unknown / not populated) is treated as not-Node.js. +func isAppNodejs(typeId int64) bool { + _, ok := NodeJsTypeIds[typeId] + return ok +} + +// PromptForReloadManifest asks "Apply this environment variable update now?". +// Returns false (no prompt) on --skip-confirmation OR non-interactive. +// For Node.js apps, prefixes with the yellow build-vs-runtime warning. +// +// Node parity (src/lib/envvar/input.ts::promptForReloadManifest): +// - The Confirm prompt itself uses `.catch(() => false)`, so any error +// becomes "no". We mirror that: ErrNonInteractive (and any other prompt +// failure) falls through to false instead of erroring the command. +func PromptForReloadManifest(cmd *cobra.Command, typeId int64, skipConfirmation bool) (bool, error) { + if skipConfirmation { + return false, nil + } + if !appctx.IsInteractive(cmd) { + return false, nil + } + emitNodejsReloadWarning(cmd.OutOrStdout(), typeId) + ok, err := appctx.Confirm(cmd, "Apply this environment variable update now?", false) + if err != nil { + // Node parity: any prompt failure (incl. ErrNonInteractive) falls + // through as "no, don't reload" instead of erroring the command. + return false, nil + } + return ok, nil +} + +// emitNodejsReloadWarning prints the Node.js-specific build-vs-runtime +// notice. No-op for non-Node.js typeIds (or unknown typeId == 0). +// +// Node parity wording (input.ts): +// +// ⚠️ Note: Only applies to runtime variable changes. Build-time +// environment variable changes won't take effect until your next deploy. +// +// The whole line is yellow; "Only applies to runtime variable changes." +// is additionally bolded. +func emitNodejsReloadWarning(stdout io.Writer, typeId int64) { + if !isAppNodejs(typeId) { + return + } + // Inner span is bold-only; outer YellowString already paints the whole + // line yellow. Adding FgYellow inside would re-emit the yellow code + // inside an already-yellow span (Node uses chalk.bold inside chalk.yellow). + fmt.Fprintln(stdout, color.YellowString( + "⚠️ Note: %s Build-time environment variable changes won't take effect until your next deploy.", + color.New(color.Bold).Sprint("Only applies to runtime variable changes."), + )) +} + +// ShowDeployWarning prints the post-mutation "won't be available until the +// next deploy" reminder. Called by set/delete on the success path when +// reloadManifest=false AND not --skip-confirmation. Mirrors Node's +// showDeployWarning() in src/lib/envvar/input.ts: +// +// Important: This environment variable update will not be available +// until the next code deploy is made to this environment. +// +// "Important:" is bold + yellow background; the rest is plain. Node uses +// chalk.bgYellow(chalk.bold(...)), which leaves the foreground to the +// terminal's default — we match that by NOT forcing FgBlack/FgWhite. +func ShowDeployWarning(stdout io.Writer) { + fmt.Fprintf(stdout, "%s %s\n", + color.New(color.BgYellow, color.Bold).Sprint("Important:"), + "This environment variable update will not be available until the next code deploy is made to this environment.") +} diff --git a/internal/envvar/reload_manifest_test.go b/internal/envvar/reload_manifest_test.go new file mode 100644 index 000000000..5cf908ba8 --- /dev/null +++ b/internal/envvar/reload_manifest_test.go @@ -0,0 +1,44 @@ +package envvar + +import ( + "bytes" + "strings" + "testing" +) + +func TestPromptForReloadManifestNodejsTypeIdEmitsWarning(t *testing.T) { + var stdout bytes.Buffer + emitNodejsReloadWarning(&stdout, 3) + if !strings.Contains(stdout.String(), "Only applies to runtime variable changes") { + t.Errorf("Node.js typeId (3) should emit runtime/build-time warning; got %q", stdout.String()) + } +} + +func TestPromptForReloadManifestWordPressTypeIdSilent(t *testing.T) { + var stdout bytes.Buffer + emitNodejsReloadWarning(&stdout, 2) + if stdout.Len() != 0 { + t.Errorf("non-Node.js typeId must not emit warning; got %q", stdout.String()) + } +} + +func TestIsAppNodejs(t *testing.T) { + for _, id := range []int64{3, 5, 7, 8} { + if !isAppNodejs(id) { + t.Errorf("typeId %d must be Node.js", id) + } + } + for _, id := range []int64{0, 1, 2, 6, 99} { + if isAppNodejs(id) { + t.Errorf("typeId %d must NOT be Node.js", id) + } + } +} + +func TestShowDeployWarningIncludesImportantLabel(t *testing.T) { + var stdout bytes.Buffer + ShowDeployWarning(&stdout) + if !strings.Contains(stdout.String(), "Important:") || !strings.Contains(stdout.String(), "next code deploy") { + t.Errorf("ShowDeployWarning output missing expected text; got %q", stdout.String()) + } +} diff --git a/internal/envvar/value_confirm.go b/internal/envvar/value_confirm.go new file mode 100644 index 000000000..f18024103 --- /dev/null +++ b/internal/envvar/value_confirm.go @@ -0,0 +1,26 @@ +// Package envvar — value-echo confirm helper for vip config envvar set --from-file. +package envvar + +import ( + "fmt" + "io" +) + +// EchoValueForConfirm prints the read-from-file value between Node-parity +// banners so the user can confirm before the mutation fires. Called from +// runEnvvarSet when --from-file is used AND --skip-confirmation is NOT set. +// +// Output shape mirrors src/bin/vip-config-envvar-set.js exactly: +// +// ===== Received value printed below ===== +// +// ===== Received value printed above ===== +// +// +// Caller follows up with `appctx.Confirm(cmd, "Please confirm the input value above", false)`. +func EchoValueForConfirm(stdout io.Writer, value string) { + fmt.Fprintln(stdout, "===== Received value printed below =====") + fmt.Fprintln(stdout, value) + fmt.Fprintln(stdout, "===== Received value printed above =====") + fmt.Fprintln(stdout) +} diff --git a/internal/envvar/value_confirm_test.go b/internal/envvar/value_confirm_test.go new file mode 100644 index 000000000..857c30448 --- /dev/null +++ b/internal/envvar/value_confirm_test.go @@ -0,0 +1,22 @@ +package envvar + +import ( + "bytes" + "strings" + "testing" +) + +func TestEchoValueForConfirmBetweenBanners(t *testing.T) { + var stdout bytes.Buffer + EchoValueForConfirm(&stdout, "hello\nworld") + out := stdout.String() + if !strings.Contains(out, "===== Received value printed below =====") { + t.Errorf("missing opening banner; got %q", out) + } + if !strings.Contains(out, "===== Received value printed above =====") { + t.Errorf("missing closing banner; got %q", out) + } + if !strings.Contains(out, "hello\nworld") { + t.Errorf("value not echoed verbatim; got %q", out) + } +} diff --git a/internal/logsapi/logsapi.go b/internal/logsapi/logsapi.go new file mode 100644 index 000000000..82a76bdf2 --- /dev/null +++ b/internal/logsapi/logsapi.go @@ -0,0 +1,165 @@ +// Package logsapi wraps the GetAppLogs genqlient operation behind a flat +// Go-friendly surface. The schema field is `AppEnvironment.logs(type, +// limit, after)` and returns `AppEnvironmentLogsList` (`nodes`, +// `nextCursor`, `pollingDelaySeconds`). +// +// The Node parity source is src/lib/app-logs/app-logs.ts (getRecentLogs). +// +// We walk the genqlient response via reflection — mirroring the envvar +// package — so callers don't need to know the deeply-nested generated +// type names (e.g. GetAppLogsAppEnvironmentsAppEnvironmentLogsAppEnvironmentLogsList). +package logsapi + +import ( + "context" + "reflect" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/gql" +) + +// LIMIT_MAX is the server-side ceiling for the `limit` argument on the +// logs query. Mirrors Node's app-logs.ts export. Callers (the polling +// loop in particular) use this as the cap on subsequent fetches. +const LIMIT_MAX = 5000 + +// LogNode is one log line: a timestamp + message. +type LogNode struct { + Timestamp string + Message string +} + +// Page is a single response page from the logs endpoint. +type Page struct { + Nodes []LogNode + NextCursor *string + PollingDelaySeconds int +} + +// RecentLogs runs GetAppLogs and flattens the response into a Page. The +// logType must be one of `app` or `batch` — validation lives at the +// command-line layer to match Node's exact error wording. +func RecentLogs(ctx context.Context, c graphql.Client, appID, envID int64, logType string, limit int, after *string) (*Page, error) { + resp, err := gql.GetAppLogs(ctx, c, appID, envID, gql.AppEnvironmentLogType(logType), int64(limit), after) + if err != nil { + return nil, err + } + return reflectLogsResponse(resp), nil +} + +// reflectLogsResponse walks app → environments[0] → logs → {nodes, +// nextCursor, pollingDelaySeconds}. Uses reflection to avoid coupling +// to genqlient's verbose generated type names (which change whenever +// the operation shape changes). Returns an empty Page (non-nil) on any +// missing field — the command layer treats len(Nodes)==0 as the +// "no logs found" case. +func reflectLogsResponse(v any) *Page { + p := &Page{Nodes: []LogNode{}} + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Ptr { + if rv.IsNil() { + return p + } + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return p + } + app := rv.FieldByName("App") + for app.Kind() == reflect.Ptr { + if app.IsNil() { + return p + } + app = app.Elem() + } + if !app.IsValid() || app.Kind() != reflect.Struct { + return p + } + envs := app.FieldByName("Environments") + if !envs.IsValid() || envs.Kind() != reflect.Slice || envs.Len() == 0 { + return p + } + env := envs.Index(0) + for env.Kind() == reflect.Ptr { + if env.IsNil() { + return p + } + env = env.Elem() + } + if env.Kind() != reflect.Struct { + return p + } + logs := env.FieldByName("Logs") + for logs.Kind() == reflect.Ptr { + if logs.IsNil() { + return p + } + logs = logs.Elem() + } + if !logs.IsValid() || logs.Kind() != reflect.Struct { + return p + } + if nc := logs.FieldByName("NextCursor"); nc.IsValid() { + switch nc.Kind() { + case reflect.Ptr: + if !nc.IsNil() { + s := nc.Elem().String() + p.NextCursor = &s + } + case reflect.String: + s := nc.String() + p.NextCursor = &s + } + } + if pd := logs.FieldByName("PollingDelaySeconds"); pd.IsValid() { + switch pd.Kind() { + case reflect.Ptr: + if !pd.IsNil() { + p.PollingDelaySeconds = int(pd.Elem().Int()) + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + p.PollingDelaySeconds = int(pd.Int()) + } + } + nodes := logs.FieldByName("Nodes") + if !nodes.IsValid() || nodes.Kind() != reflect.Slice { + return p + } + for i := 0; i < nodes.Len(); i++ { + n := nodes.Index(i) + for n.Kind() == reflect.Ptr { + if n.IsNil() { + n = reflect.Value{} + break + } + n = n.Elem() + } + if !n.IsValid() || n.Kind() != reflect.Struct { + continue + } + var item LogNode + if f := n.FieldByName("Timestamp"); f.IsValid() { + switch f.Kind() { + case reflect.Ptr: + if !f.IsNil() { + item.Timestamp = f.Elem().String() + } + case reflect.String: + item.Timestamp = f.String() + } + } + if f := n.FieldByName("Message"); f.IsValid() { + switch f.Kind() { + case reflect.Ptr: + if !f.IsNil() { + item.Message = f.Elem().String() + } + case reflect.String: + item.Message = f.String() + } + } + p.Nodes = append(p.Nodes, item) + } + return p +} diff --git a/internal/logsapi/logsapi_test.go b/internal/logsapi/logsapi_test.go new file mode 100644 index 000000000..6090a6dd3 --- /dev/null +++ b/internal/logsapi/logsapi_test.go @@ -0,0 +1,82 @@ +package logsapi + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Khan/genqlient/graphql" +) + +// logsServer returns a stub /graphql endpoint that responds with the given +// JSON body for every request. Sufficient because each RecentLogs call +// fires exactly one query. +func logsServer(body string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) +} + +func TestRecentLogsHappyPath(t *testing.T) { + srv := logsServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"logs":{"nodes":[{"timestamp":"2024-01-01T00:00:00Z","message":"hello"},{"timestamp":"2024-01-01T00:00:01Z","message":"world"}],"nextCursor":"abc","pollingDelaySeconds":7}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + page, err := RecentLogs(context.Background(), c, 1, 2, "app", 500, nil) + if err != nil { + t.Fatalf("RecentLogs: %v", err) + } + if len(page.Nodes) != 2 { + t.Fatalf("Nodes len = %d, want 2 (page=%+v)", len(page.Nodes), page) + } + if page.Nodes[0].Timestamp != "2024-01-01T00:00:00Z" || page.Nodes[0].Message != "hello" { + t.Errorf("Nodes[0] = %+v, want {2024-01-01T00:00:00Z hello}", page.Nodes[0]) + } + if page.Nodes[1].Timestamp != "2024-01-01T00:00:01Z" || page.Nodes[1].Message != "world" { + t.Errorf("Nodes[1] = %+v, want {2024-01-01T00:00:01Z world}", page.Nodes[1]) + } + if page.NextCursor == nil || *page.NextCursor != "abc" { + t.Errorf("NextCursor = %v, want abc", page.NextCursor) + } + if page.PollingDelaySeconds != 7 { + t.Errorf("PollingDelaySeconds = %d, want 7", page.PollingDelaySeconds) + } +} + +func TestRecentLogsEmpty(t *testing.T) { + srv := logsServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"logs":{"nodes":[],"nextCursor":null,"pollingDelaySeconds":15}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + page, err := RecentLogs(context.Background(), c, 1, 2, "app", 500, nil) + if err != nil { + t.Fatalf("RecentLogs: %v", err) + } + if len(page.Nodes) != 0 { + t.Errorf("Nodes len = %d, want 0; page=%+v", len(page.Nodes), page) + } + if page.NextCursor != nil { + t.Errorf("NextCursor = %v, want nil", page.NextCursor) + } + if page.PollingDelaySeconds != 15 { + t.Errorf("PollingDelaySeconds = %d, want 15", page.PollingDelaySeconds) + } +} + +func TestRecentLogsBatchType(t *testing.T) { + // Same payload as happy path, but with the batch type — exercising the + // enum-cast path through gql.AppEnvironmentLogType. + srv := logsServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"logs":{"nodes":[{"timestamp":"t","message":"m"}],"pollingDelaySeconds":30}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + page, err := RecentLogs(context.Background(), c, 1, 2, "batch", 100, nil) + if err != nil { + t.Fatalf("RecentLogs(batch): %v", err) + } + if len(page.Nodes) != 1 || page.Nodes[0].Message != "m" { + t.Errorf("Nodes = %+v, want one {t m}", page.Nodes) + } +} diff --git a/internal/phpmyadmin/client.go b/internal/phpmyadmin/client.go new file mode 100644 index 000000000..e659bf5fc --- /dev/null +++ b/internal/phpmyadmin/client.go @@ -0,0 +1,221 @@ +// Package phpmyadmin implements the enable + poll + generate flow for +// `vip db phpmyadmin`. The Node implementation in src/commands/phpmyadmin.ts +// treats this as one user-visible operation but internally fires up to three +// GraphQL operations, gated by maybeEnablePhpMyAdmin (phpmyadmin.ts:213): +// +// private async maybeEnablePhpMyAdmin(): Promise< void > { +// const status = await this.getStatus(); +// if ( ! [ 'running', 'enabled' ].includes( status ) ) { +// await enablePhpMyAdmin( this.env.id as number ); +// await pollUntil( this.getStatus.bind( this ), 1000, ( sts: string ) => sts === 'running' ); +// // Additional 30s for LB routing to be updated +// await setTimeout( 30_000 ); +// } +// } +// +// So: +// +// 1. PhpMyAdminStatus query — always. When it already reads "running" or +// "enabled" the whole enable branch is skipped: no mutation, no poll, no +// load-balancer wait. +// 2. EnablePhpMyAdmin mutation — only when the environment is not already up. +// 3. PhpMyAdminStatus polled at a 1s tick until status == "running", under +// pollUntil's default 6h ceiling (utils.ts:18) — NOT a 60s one; a cold +// environment can legitimately take many minutes. +// 4. A 30s settle for LB routing, then GeneratePhpMyAdminAccess. +package phpmyadmin + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + "github.com/Khan/genqlient/graphql" + "github.com/vektah/gqlparser/v2/gqlerror" + + "github.com/Automattic/vip/internal/gql" + "github.com/Automattic/vip/internal/poll" +) + +// Node's timings (src/commands/phpmyadmin.ts:217,220 + src/lib/utils.ts:18). +const ( + // DefaultPollInterval is pollUntil's 1000ms tick. + DefaultPollInterval = 1 * time.Second + // DefaultPollTimeout is pollUntil's default ceiling: Node passes no + // timeout here, so the poll may legitimately run for six hours. + DefaultPollTimeout = poll.DefaultTimeout + // DefaultPostEnableWait is the "Additional 30s for LB routing to be + // updated" settle after a cold enable. + DefaultPostEnableWait = 30 * time.Second +) + +// RunOpts configures Run. Stderr is the progress sink. The durations are +// exposed so callers (and tests) can shorten the waits; leave them zero to +// pick up Node's values. +type RunOpts struct { + Silent bool + Stderr io.Writer + PollInterval time.Duration + PollTimeout time.Duration + // PostEnableWait is the LB settle after enabling. A negative value + // skips it; zero means DefaultPostEnableWait. + PostEnableWait time.Duration + + // sleep is the clock seam for PostEnableWait. Production leaves it nil + // (time.Sleep); tests inject a recorder so the 30s settle costs nothing. + sleep func(time.Duration) +} + +// resolveRunOpts fills in Node's defaults for anything the caller left zero. +// It is a plain function so the resolved ceiling can be asserted directly — +// proving the poll really runs with the 6h value without a 6h test. +func resolveRunOpts(o RunOpts) RunOpts { + if o.Stderr == nil { + o.Stderr = io.Discard + } + if o.PollInterval == 0 { + o.PollInterval = DefaultPollInterval + } + if o.PollTimeout == 0 { + o.PollTimeout = DefaultPollTimeout + } + if o.PostEnableWait == 0 { + o.PostEnableWait = DefaultPostEnableWait + } + if o.sleep == nil { + o.sleep = time.Sleep + } + return o +} + +// Result is what Run returns on success. +type Result struct { + URL string +} + +const ( + permissionErrorMessage = "You do not have sufficient permission to access phpMyAdmin for this environment." + enableErrorMessage = "Failed to enable phpMyAdmin. Please try again. If the problem persists, please contact support." +) + +type userError struct { + message string + cause error +} + +func (e *userError) Error() string { return e.message } +func (e *userError) Unwrap() error { return e.cause } + +func enableFailure(err error) error { + if hasGraphQLErrorMessage(err, "Unauthorized") { + return &userError{message: permissionErrorMessage, cause: err} + } + return &userError{message: enableErrorMessage, cause: err} +} + +func generateFailure(err error) error { + return &userError{message: "Failed to generate phpMyAdmin URL: " + err.Error(), cause: err} +} + +func hasGraphQLErrorMessage(err error, want string) bool { + var list gqlerror.List + if errors.As(err, &list) { + for _, item := range list { + if item != nil && item.Message == want { + return true + } + } + } + var single *gqlerror.Error + return errors.As(err, &single) && single != nil && single.Message == want +} + +// Run executes the flow. Returns the generated access URL on success, or a +// wrapped error on any step's failure. +func Run(ctx context.Context, c graphql.Client, appID, envID int64, opts RunOpts) (*Result, error) { + opts = resolveRunOpts(opts) + + getStatus := func(ctx context.Context) (string, error) { + statusResp, err := gql.PhpMyAdminStatus(ctx, c, appID, envID) + if err != nil { + return "", err + } + return readPhpMyAdminStatus(statusResp), nil + } + + // Node's progress tracker marks the ENABLE step running before + // maybeEnablePhpMyAdmin, whether or not the mutation ends up firing. + if !opts.Silent { + fmt.Fprintln(opts.Stderr, "Enabling phpMyAdmin for this environment...") + } + + // 1. Status first. This is the short-circuit Go was missing: without it + // every single invocation fired an extra enable mutation. + status, err := getStatus(ctx) + if err != nil { + return nil, enableFailure(err) + } + + if status != "running" && status != "enabled" { + // 2. Enable. + enableInput := &gql.EnablePhpMyAdminInput{EnvironmentId: envID} + enableResp, err := gql.EnablePhpMyAdmin(ctx, c, enableInput) + if err != nil { + return nil, enableFailure(err) + } + if enableResp == nil || enableResp.EnablePHPMyAdmin == nil || + enableResp.EnablePHPMyAdmin.Success == nil || !*enableResp.EnablePHPMyAdmin.Success { + return nil, enableFailure(errors.New("phpMyAdmin enablement did not succeed")) + } + + // 3. Poll status until "running", under the 6h ceiling. + if !opts.Silent { + fmt.Fprintln(opts.Stderr, "Waiting for phpMyAdmin to be ready...") + } + last, perr := poll.Until(ctx, getStatus, opts.PollInterval, + func(s string) bool { return s == "running" }, opts.PollTimeout) + if perr != nil { + return nil, enableFailure(fmt.Errorf("poll phpMyAdmin status (last status %q): %w", last, perr)) + } + + // 4. LB settle. + if opts.PostEnableWait > 0 { + opts.sleep(opts.PostEnableWait) + } + } + + // 5. Generate access URL. + if !opts.Silent { + fmt.Fprintln(opts.Stderr, "Generating phpMyAdmin access link...") + } + genInput := &gql.GeneratePhpMyAdminAccessInput{EnvironmentId: envID} + genResp, err := gql.GeneratePhpMyAdminAccess(ctx, c, genInput) + if err != nil { + return nil, generateFailure(err) + } + if genResp == nil || genResp.GeneratePHPMyAdminAccess == nil || + genResp.GeneratePHPMyAdminAccess.Url == nil || *genResp.GeneratePHPMyAdminAccess.Url == "" { + return nil, generateFailure(errors.New("phpMyAdmin access response missing URL")) + } + return &Result{URL: *genResp.GeneratePHPMyAdminAccess.Url}, nil +} + +// readPhpMyAdminStatus pulls resp.App.Environments[0].PhpMyAdminStatus.Status +// in a nil-safe way. Genqlient generates pointers all the way down for +// optional fields, so we have to walk carefully. +func readPhpMyAdminStatus(resp *gql.PhpMyAdminStatusResponse) string { + if resp == nil || resp.App == nil { + return "" + } + envs := resp.App.Environments + if len(envs) == 0 || envs[0] == nil { + return "" + } + pma := envs[0].PhpMyAdminStatus + if pma == nil || pma.Status == nil { + return "" + } + return *pma.Status +} diff --git a/internal/phpmyadmin/client_test.go b/internal/phpmyadmin/client_test.go new file mode 100644 index 000000000..58a5519b3 --- /dev/null +++ b/internal/phpmyadmin/client_test.go @@ -0,0 +1,418 @@ +package phpmyadmin + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Khan/genqlient/graphql" +) + +// fakeServer dispatches on the GraphQL operationName in the request body. +// Each handler can be set per test; nil handlers default to a generic 200 +// with `{"data":null}` which would tell us via assertion that the test +// forgot to wire that op. +type fakeServer struct { + enable func(w http.ResponseWriter, r *http.Request) + status func(w http.ResponseWriter, r *http.Request) + generate func(w http.ResponseWriter, r *http.Request) + + enableHits int32 + statusHits int32 + generateHits int32 +} + +func (f *fakeServer) serve(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + s := string(body) + switch { + case strings.Contains(s, `"operationName":"EnablePhpMyAdmin"`): + atomic.AddInt32(&f.enableHits, 1) + if f.enable != nil { + f.enable(w, r) + return + } + case strings.Contains(s, `"operationName":"PhpMyAdminStatus"`): + atomic.AddInt32(&f.statusHits, 1) + if f.status != nil { + f.status(w, r) + return + } + case strings.Contains(s, `"operationName":"GeneratePhpMyAdminAccess"`): + atomic.AddInt32(&f.generateHits, 1) + if f.generate != nil { + f.generate(w, r) + return + } + } + // Default: respond with an empty data payload so unhandled ops fail + // downstream assertion rather than hang. + _, _ = w.Write([]byte(`{"data":null}`)) +} + +func newClient(t *testing.T, srv *httptest.Server) graphql.Client { + t.Helper() + return graphql.NewClient(srv.URL, srv.Client()) +} + +// TestRunHappyPath: status is already "running", so Node's +// maybeEnablePhpMyAdmin (phpmyadmin.ts:213-222) short-circuits — NO enable +// mutation, NO poll loop, NO post-enable wait — and we go straight to +// generate. +// +// const status = await this.getStatus(); +// if ( ! [ 'running', 'enabled' ].includes( status ) ) { … } +// +// Go used to fire the enable mutation unconditionally on every invocation. +func TestRunHappyPath(t *testing.T) { + fs := &fakeServer{ + enable: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"enablePHPMyAdmin":{"success":true}}}`)) + }, + status: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"running"}}]}}}`)) + }, + generate: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"generatePHPMyAdminAccess":{"url":"https://pma.example/abc"}}}`)) + }, + } + srv := httptest.NewServer(http.HandlerFunc(fs.serve)) + defer srv.Close() + + waits := 0 + var stderr bytes.Buffer + res, err := Run(context.Background(), newClient(t, srv), 1, 2, RunOpts{ + Stderr: &stderr, + PollInterval: 1 * time.Millisecond, + PollTimeout: 1 * time.Second, + PostEnableWait: time.Hour, // would hang the test if it were honoured + sleep: func(time.Duration) { waits++ }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.URL != "https://pma.example/abc" { + t.Errorf("URL = %q, want https://pma.example/abc", res.URL) + } + if fs.enableHits != 0 { + t.Errorf("enable hits = %d, want 0: phpMyAdmin is already running", fs.enableHits) + } + if fs.statusHits != 1 || fs.generateHits != 1 { + t.Errorf("status/generate hits = %d/%d, want 1/1", fs.statusHits, fs.generateHits) + } + if waits != 0 { + t.Errorf("post-enable wait ran %d times, want 0 (nothing was enabled)", waits) + } + // Progress lines must hit stderr by default. + if !strings.Contains(stderr.String(), "phpMyAdmin") { + t.Errorf("stderr missing progress; got=%q", stderr.String()) + } +} + +// TestRunSkipsEnableWhenStatusIsEnabled: "enabled" is the second value in +// Node's short-circuit list, and it skips the poll loop too — an env that +// reports "enabled" (never "running") must NOT wedge for 6 hours. +func TestRunSkipsEnableWhenStatusIsEnabled(t *testing.T) { + fs := &fakeServer{ + status: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"enabled"}}]}}}`)) + }, + generate: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"generatePHPMyAdminAccess":{"url":"https://pma.example/en"}}}`)) + }, + } + srv := httptest.NewServer(http.HandlerFunc(fs.serve)) + defer srv.Close() + + done := make(chan error, 1) + go func() { + _, err := Run(context.Background(), newClient(t, srv), 1, 2, RunOpts{ + Stderr: io.Discard, + PollInterval: 1 * time.Millisecond, + PollTimeout: 1 * time.Second, + PostEnableWait: 0, + sleep: func(time.Duration) {}, + }) + done <- err + }() + select { + case err := <-done: + if err != nil { + t.Fatalf("Run: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Run never returned on an 'enabled' environment") + } + if fs.enableHits != 0 { + t.Errorf("enable hits = %d, want 0 for status 'enabled'", fs.enableHits) + } + if fs.statusHits != 1 { + t.Errorf("status hits = %d, want 1: 'enabled' must not enter the poll loop", fs.statusHits) + } +} + +// TestRunWaitsForLoadBalancerAfterEnabling ports the last line of +// maybeEnablePhpMyAdmin: `await setTimeout( 30_000 )` — "Additional 30s for +// LB routing to be updated" (phpmyadmin.ts:219-220). It runs ONLY on the +// branch that actually enabled. +func TestRunWaitsForLoadBalancerAfterEnabling(t *testing.T) { + statusCalls := int32(0) + fs := &fakeServer{ + enable: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"enablePHPMyAdmin":{"success":true}}}`)) + }, + status: func(w http.ResponseWriter, _ *http.Request) { + if atomic.AddInt32(&statusCalls, 1) == 1 { + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"stopped"}}]}}}`)) + return + } + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"running"}}]}}}`)) + }, + generate: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"generatePHPMyAdminAccess":{"url":"https://pma.example/lb"}}}`)) + }, + } + srv := httptest.NewServer(http.HandlerFunc(fs.serve)) + defer srv.Close() + + var slept []time.Duration + _, err := Run(context.Background(), newClient(t, srv), 1, 2, RunOpts{ + Stderr: io.Discard, + PollInterval: 1 * time.Millisecond, + PollTimeout: 1 * time.Second, + sleep: func(d time.Duration) { slept = append(slept, d) }, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if fs.enableHits != 1 { + t.Errorf("enable hits = %d, want 1 (status was 'stopped')", fs.enableHits) + } + if len(slept) != 1 || slept[0] != DefaultPostEnableWait { + t.Errorf("post-enable waits = %v, want [%v]", slept, DefaultPostEnableWait) + } +} + +// TestDefaultPollTimeoutIsNodesSixHourCeiling: Node's poll here inherits the +// pollUntil default (phpmyadmin.ts:217 passes no timeout), so the ceiling is +// 6 hours. Go capped it at 60 seconds, aborting slow-but-healthy enables. +func TestDefaultPollTimeoutIsNodesSixHourCeiling(t *testing.T) { + if DefaultPollTimeout != 6*time.Hour { + t.Errorf("DefaultPollTimeout = %v, want 6h", DefaultPollTimeout) + } + if DefaultPollInterval != time.Second { + t.Errorf("DefaultPollInterval = %v, want 1s (phpmyadmin.ts:217)", DefaultPollInterval) + } + if DefaultPostEnableWait != 30*time.Second { + t.Errorf("DefaultPostEnableWait = %v, want 30s (phpmyadmin.ts:220)", DefaultPostEnableWait) + } +} + +// TestRunUsesDefaultCeilingWhenUnset closes the gap between "the constant is +// 6h" and "the loop actually runs with 6h": Run resolves a zero PollTimeout +// through resolveRunOpts, which is the value the poll loop is handed. +func TestRunUsesDefaultCeilingWhenUnset(t *testing.T) { + got := resolveRunOpts(RunOpts{}) + if got.PollTimeout != DefaultPollTimeout { + t.Errorf("resolved PollTimeout = %v, want %v", got.PollTimeout, DefaultPollTimeout) + } + if got.PollInterval != DefaultPollInterval { + t.Errorf("resolved PollInterval = %v, want %v", got.PollInterval, DefaultPollInterval) + } + if got.PostEnableWait != DefaultPostEnableWait { + t.Errorf("resolved PostEnableWait = %v, want %v", got.PostEnableWait, DefaultPostEnableWait) + } + // Explicit values survive resolution (that is what makes the ceiling + // testable without a six-hour test). + explicit := resolveRunOpts(RunOpts{PollTimeout: time.Minute, PollInterval: time.Second, PostEnableWait: -1}) + if explicit.PollTimeout != time.Minute { + t.Errorf("explicit PollTimeout was overwritten: %v", explicit.PollTimeout) + } + if explicit.PostEnableWait != -1 { + t.Errorf("explicit PostEnableWait was overwritten: %v", explicit.PostEnableWait) + } +} + +// TestRunPolling: first status "pending" then "running" — must complete +// after one poll iteration. +func TestRunPolling(t *testing.T) { + statusCalls := int32(0) + fs := &fakeServer{ + enable: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"enablePHPMyAdmin":{"success":true}}}`)) + }, + status: func(w http.ResponseWriter, _ *http.Request) { + n := atomic.AddInt32(&statusCalls, 1) + if n == 1 { + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"pending"}}]}}}`)) + return + } + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"running"}}]}}}`)) + }, + generate: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"generatePHPMyAdminAccess":{"url":"https://pma.example/xyz"}}}`)) + }, + } + srv := httptest.NewServer(http.HandlerFunc(fs.serve)) + defer srv.Close() + + res, err := Run(context.Background(), newClient(t, srv), 1, 2, RunOpts{ + Stderr: io.Discard, + PollInterval: 1 * time.Millisecond, + PollTimeout: 1 * time.Second, + sleep: func(time.Duration) {}, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if res.URL != "https://pma.example/xyz" { + t.Errorf("URL = %q, want https://pma.example/xyz", res.URL) + } + if statusCalls < 2 { + t.Errorf("status calls = %d, want >= 2 (polling kicked in)", statusCalls) + } +} + +// TestRunSilentSuppressesStderr confirms Silent skips the progress lines. +func TestRunSilentSuppressesStderr(t *testing.T) { + fs := &fakeServer{ + enable: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"enablePHPMyAdmin":{"success":true}}}`)) + }, + status: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"running"}}]}}}`)) + }, + generate: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"generatePHPMyAdminAccess":{"url":"https://pma.example/q"}}}`)) + }, + } + srv := httptest.NewServer(http.HandlerFunc(fs.serve)) + defer srv.Close() + + var stderr bytes.Buffer + _, err := Run(context.Background(), newClient(t, srv), 1, 2, RunOpts{ + Silent: true, + Stderr: &stderr, + PollInterval: 1 * time.Millisecond, + PollTimeout: 1 * time.Second, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if stderr.Len() != 0 { + t.Errorf("silent mode wrote to stderr: %q", stderr.String()) + } +} + +// TestRunEnableUnauthorized maps the backend detail to Node's actionable +// permission message while preserving a non-zero result. +func TestRunEnableUnauthorized(t *testing.T) { + fs := &fakeServer{ + enable: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"errors":[{"message":"Unauthorized"}],"data":null}`)) + }, + } + srv := httptest.NewServer(http.HandlerFunc(fs.serve)) + defer srv.Close() + + _, err := Run(context.Background(), newClient(t, srv), 1, 2, RunOpts{ + Stderr: io.Discard, + PollInterval: 1 * time.Millisecond, + PollTimeout: 100 * time.Millisecond, + }) + if err == nil { + t.Fatal("expected error, got nil") + } + const want = "You do not have sufficient permission to access phpMyAdmin for this environment." + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +func TestRunEnableFailureUsesStableSupportMessage(t *testing.T) { + fs := &fakeServer{ + enable: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"errors":[{"message":"backend exploded"}],"data":null}`)) + }, + } + srv := httptest.NewServer(http.HandlerFunc(fs.serve)) + defer srv.Close() + + _, err := Run(context.Background(), newClient(t, srv), 1, 2, RunOpts{ + Stderr: io.Discard, + PollInterval: 1 * time.Millisecond, + PollTimeout: 100 * time.Millisecond, + }) + if err == nil { + t.Fatal("expected error, got nil") + } + const want = "Failed to enable phpMyAdmin. Please try again. If the problem persists, please contact support." + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +// TestRunPollTimeout: status never reaches "running" — must error after +// PollTimeout elapses. +func TestRunPollTimeout(t *testing.T) { + fs := &fakeServer{ + enable: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"enablePHPMyAdmin":{"success":true}}}`)) + }, + status: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"pending"}}]}}}`)) + }, + } + srv := httptest.NewServer(http.HandlerFunc(fs.serve)) + defer srv.Close() + + _, err := Run(context.Background(), newClient(t, srv), 1, 2, RunOpts{ + Stderr: io.Discard, + PollInterval: 1 * time.Millisecond, + PollTimeout: 20 * time.Millisecond, + }) + if err == nil { + t.Fatal("expected timeout error, got nil") + } + const want = "Failed to enable phpMyAdmin. Please try again. If the problem persists, please contact support." + if err.Error() != want { + t.Errorf("error = %q, want %q", err.Error(), want) + } +} + +// TestRunGenerateFailure: enable+poll succeed but generate errors — +// surface as error. +func TestRunGenerateFailure(t *testing.T) { + fs := &fakeServer{ + enable: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"enablePHPMyAdmin":{"success":true}}}`)) + }, + status: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"app":{"environments":[{"phpMyAdminStatus":{"status":"running"}}]}}}`)) + }, + generate: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"errors":[{"message":"boom"}],"data":null}`)) + }, + } + srv := httptest.NewServer(http.HandlerFunc(fs.serve)) + defer srv.Close() + + _, err := Run(context.Background(), newClient(t, srv), 1, 2, RunOpts{ + Stderr: io.Discard, + PollInterval: 1 * time.Millisecond, + PollTimeout: 1 * time.Second, + }) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.HasPrefix(err.Error(), "Failed to generate phpMyAdmin URL: ") { + t.Errorf("error doesn't use the stable URL-generation prefix: %v", err) + } +} diff --git a/internal/slowlogsapi/slowlogsapi.go b/internal/slowlogsapi/slowlogsapi.go new file mode 100644 index 000000000..5678dc87b --- /dev/null +++ b/internal/slowlogsapi/slowlogsapi.go @@ -0,0 +1,176 @@ +// Package slowlogsapi wraps the GetAppSlowlogs genqlient operation behind +// a flat Go-friendly surface. The schema field is +// `AppEnvironment.slowlogs(limit, after)` and returns +// `AppEnvironmentSlowlogsList` (`nodes`, `nextCursor`, +// `pollingDelaySeconds`). +// +// Node parity: src/lib/app-slowlogs/app-slowlogs.ts (getRecentSlowlogs). +// The reflection walker matches internal/logsapi but yields a richer row +// shape: timestamp, rowsSent, rowsExamined, queryTime, requestUri, query. +package slowlogsapi + +import ( + "context" + "reflect" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/gql" +) + +// LIMIT_MAX is the server-side ceiling for the `limit` argument on the +// slowlogs query. Node's vip-slowlogs.ts uses 500 as the validation cap +// (vs 5000 for runtime logs); slowlogs are intentionally smaller-batched +// to keep the MySQL slow-query window manageable. +const LIMIT_MAX = 500 + +// SlowlogNode is one slow-query log line. All fields are strings on the +// wire (the schema exposes them as String, including rowsSent/rowsExamined +// which are numeric in MySQL but serialized as text to preserve bigint +// precision). +type SlowlogNode struct { + Timestamp string + RowsSent string + RowsExamined string + QueryTime string + RequestUri string + Query string +} + +// Page is a single response page from the slowlogs endpoint. +type Page struct { + Nodes []SlowlogNode + NextCursor *string + PollingDelaySeconds int +} + +// RecentSlowlogs runs GetAppSlowlogs and flattens the response. Validation +// (limit bounds, format allow-list) lives at the command-line layer to +// match Node's exact error wording. +func RecentSlowlogs(ctx context.Context, c graphql.Client, appID, envID int64, limit int, after *string) (*Page, error) { + resp, err := gql.GetAppSlowlogs(ctx, c, appID, envID, int64(limit), after) + if err != nil { + return nil, err + } + return reflectSlowlogsResponse(resp), nil +} + +// reflectSlowlogsResponse mirrors logsapi.reflectLogsResponse but pulls +// the six slowlog-specific fields from each node. Same defensive shape: +// returns an empty Page on any missing parent field. +func reflectSlowlogsResponse(v any) *Page { + p := &Page{Nodes: []SlowlogNode{}} + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Ptr { + if rv.IsNil() { + return p + } + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return p + } + app := rv.FieldByName("App") + for app.Kind() == reflect.Ptr { + if app.IsNil() { + return p + } + app = app.Elem() + } + if !app.IsValid() || app.Kind() != reflect.Struct { + return p + } + envs := app.FieldByName("Environments") + if !envs.IsValid() || envs.Kind() != reflect.Slice || envs.Len() == 0 { + return p + } + env := envs.Index(0) + for env.Kind() == reflect.Ptr { + if env.IsNil() { + return p + } + env = env.Elem() + } + if env.Kind() != reflect.Struct { + return p + } + sl := env.FieldByName("Slowlogs") + for sl.Kind() == reflect.Ptr { + if sl.IsNil() { + return p + } + sl = sl.Elem() + } + if !sl.IsValid() || sl.Kind() != reflect.Struct { + return p + } + if nc := sl.FieldByName("NextCursor"); nc.IsValid() { + switch nc.Kind() { + case reflect.Ptr: + if !nc.IsNil() { + s := nc.Elem().String() + p.NextCursor = &s + } + case reflect.String: + s := nc.String() + p.NextCursor = &s + } + } + if pd := sl.FieldByName("PollingDelaySeconds"); pd.IsValid() { + switch pd.Kind() { + case reflect.Ptr: + if !pd.IsNil() { + p.PollingDelaySeconds = int(pd.Elem().Int()) + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + p.PollingDelaySeconds = int(pd.Int()) + } + } + nodes := sl.FieldByName("Nodes") + if !nodes.IsValid() || nodes.Kind() != reflect.Slice { + return p + } + for i := 0; i < nodes.Len(); i++ { + n := nodes.Index(i) + for n.Kind() == reflect.Ptr { + if n.IsNil() { + n = reflect.Value{} + break + } + n = n.Elem() + } + if !n.IsValid() || n.Kind() != reflect.Struct { + continue + } + var item SlowlogNode + item.Timestamp = readStringField(n, "Timestamp") + item.RowsSent = readStringField(n, "RowsSent") + item.RowsExamined = readStringField(n, "RowsExamined") + item.QueryTime = readStringField(n, "QueryTime") + item.RequestUri = readStringField(n, "RequestUri") + item.Query = readStringField(n, "Query") + p.Nodes = append(p.Nodes, item) + } + return p +} + +// readStringField yields the string value of a struct field that may be +// either `string` or `*string` (genqlient emits either depending on +// nullability + use_struct_references). Missing or nil pointer fields +// return "". +func readStringField(rv reflect.Value, name string) string { + f := rv.FieldByName(name) + if !f.IsValid() { + return "" + } + switch f.Kind() { + case reflect.Ptr: + if f.IsNil() { + return "" + } + return f.Elem().String() + case reflect.String: + return f.String() + } + return "" +} diff --git a/internal/slowlogsapi/slowlogsapi_test.go b/internal/slowlogsapi/slowlogsapi_test.go new file mode 100644 index 000000000..6d43b3c23 --- /dev/null +++ b/internal/slowlogsapi/slowlogsapi_test.go @@ -0,0 +1,93 @@ +package slowlogsapi + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Khan/genqlient/graphql" +) + +// slowlogsServer returns a stub /graphql endpoint that responds with the +// given JSON body for every request. Each RecentSlowlogs call fires one +// query — a constant body suffices. +func slowlogsServer(body string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) +} + +func TestRecentSlowlogsHappyPath(t *testing.T) { + srv := slowlogsServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"slowlogs":{"nodes":[{"timestamp":"2024-01-01T00:00:00Z","rowsSent":"10","rowsExamined":"1000","queryTime":"1.234","requestUri":"/wp-admin/edit.php","query":"SELECT * FROM wp_posts"},{"timestamp":"2024-01-01T00:00:01Z","rowsSent":"5","rowsExamined":"500","queryTime":"0.567","requestUri":"/wp-login.php","query":"SELECT * FROM wp_users"}],"nextCursor":"xyz","pollingDelaySeconds":60}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + page, err := RecentSlowlogs(context.Background(), c, 1, 2, 500, nil) + if err != nil { + t.Fatalf("RecentSlowlogs: %v", err) + } + if len(page.Nodes) != 2 { + t.Fatalf("Nodes len = %d, want 2 (page=%+v)", len(page.Nodes), page) + } + got := page.Nodes[0] + if got.Timestamp != "2024-01-01T00:00:00Z" { + t.Errorf("Nodes[0].Timestamp = %q", got.Timestamp) + } + if got.RowsSent != "10" || got.RowsExamined != "1000" || got.QueryTime != "1.234" { + t.Errorf("Nodes[0] numeric fields = (%q, %q, %q)", got.RowsSent, got.RowsExamined, got.QueryTime) + } + if got.RequestUri != "/wp-admin/edit.php" || got.Query != "SELECT * FROM wp_posts" { + t.Errorf("Nodes[0] string fields = (%q, %q)", got.RequestUri, got.Query) + } + if page.NextCursor == nil || *page.NextCursor != "xyz" { + t.Errorf("NextCursor = %v, want xyz", page.NextCursor) + } + if page.PollingDelaySeconds != 60 { + t.Errorf("PollingDelaySeconds = %d, want 60", page.PollingDelaySeconds) + } +} + +func TestRecentSlowlogsEmpty(t *testing.T) { + srv := slowlogsServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"slowlogs":{"nodes":[],"nextCursor":null,"pollingDelaySeconds":30}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + page, err := RecentSlowlogs(context.Background(), c, 1, 2, 500, nil) + if err != nil { + t.Fatalf("RecentSlowlogs: %v", err) + } + if len(page.Nodes) != 0 { + t.Errorf("Nodes len = %d, want 0; page=%+v", len(page.Nodes), page) + } + if page.NextCursor != nil { + t.Errorf("NextCursor = %v, want nil", page.NextCursor) + } + if page.PollingDelaySeconds != 30 { + t.Errorf("PollingDelaySeconds = %d, want 30", page.PollingDelaySeconds) + } +} + +func TestRecentSlowlogsNullFieldsAreEmptyStrings(t *testing.T) { + // Schema declares every node field as nullable String. A null on any + // field should surface as "" rather than panic on a nil pointer. + srv := slowlogsServer(`{"data":{"app":{"id":1,"environments":[{"id":2,"slowlogs":{"nodes":[{"timestamp":"t","rowsSent":null,"rowsExamined":null,"queryTime":"0","requestUri":null,"query":"Q"}],"pollingDelaySeconds":30}}]}}}`) + defer srv.Close() + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + + page, err := RecentSlowlogs(context.Background(), c, 1, 2, 500, nil) + if err != nil { + t.Fatalf("RecentSlowlogs: %v", err) + } + if len(page.Nodes) != 1 { + t.Fatalf("Nodes len = %d, want 1", len(page.Nodes)) + } + got := page.Nodes[0] + if got.Timestamp != "t" || got.QueryTime != "0" || got.Query != "Q" { + t.Errorf("non-null fields lost: %+v", got) + } + if got.RowsSent != "" || got.RowsExamined != "" || got.RequestUri != "" { + t.Errorf("null fields should be empty strings, got %+v", got) + } +} diff --git a/internal/softwaresettings/format.go b/internal/softwaresettings/format.go new file mode 100644 index 000000000..dab913f0a --- /dev/null +++ b/internal/softwaresettings/format.go @@ -0,0 +1,192 @@ +// Package softwaresettings contains pure formatting logic for `vip config +// software get` output. It is decoupled from cobra so it can be unit-tested +// without bringing in the full command tree. +package softwaresettings + +import ( + "sort" + "strings" +) + +// ManagedOptionKey is the sentinel value for WordPress managed updates. +const ManagedOptionKey = "managed_latest" + +// Version is an available or current software version entry. +type Version struct { + Version string + Default bool + Deprecated bool + Unstable bool +} + +// Software holds all settings for one software component. +type Software struct { + Name, Slug string + Pinned bool + Current Version + Options []Version +} + +// FormattedRow is one row of `config software get` output. +type FormattedRow struct { + Name string + Slug string + Version string + AvailableVersions any // string (non-JSON, sorted+joined) or []string (JSON) +} + +// allOptionValues ports Node's _optionsForVersion (software.ts:167-208). +// The returned order is Node's allOptions array: +// +// managed (wordpress only) → supported (option-array order) → test +// (unstable) → deprecated +// +// Node keeps deprecated entries in this list; it is the DISPLAY path +// (formatSoftwareSettings, software.ts:439 `.filter(option => +// !option.deprecated)`) that removes them, not the validation path. +func allOptionValues(s Software) []string { + var supported, test, deprecated []string + for _, o := range s.Options { + switch { + case o.Deprecated: + deprecated = append(deprecated, o.Version) + case o.Unstable: + test = append(test, o.Version) + default: + supported = append(supported, o.Version) + } + } + var out []string + if s.Slug == "wordpress" { + out = append(out, ManagedOptionKey) + } + out = append(out, supported...) + out = append(out, test...) + out = append(out, deprecated...) + return out +} + +// optionValues is the DISPLAY subset: allOptionValues minus deprecated, +// matching formatSoftwareSettings' filter (software.ts:439). +func optionValues(s Software) []string { + deprecated := make(map[string]bool, len(s.Options)) + for _, o := range s.Options { + if o.Deprecated { + deprecated[o.Version] = true + } + } + all := allOptionValues(s) + out := make([]string, 0, len(all)) + for _, v := range all { + if !deprecated[v] { + out = append(out, v) + } + } + return out +} + +func baseRow(s Software) FormattedRow { + version := s.Current.Version + if s.Slug == "wordpress" && !s.Pinned { + version += " (managed updates)" // software.ts:428-430 + } + return FormattedRow{Name: s.Name, Slug: s.Slug, Version: version} +} + +// FormatSetting formats for non-JSON output (available_versions sorted + comma-joined). +func FormatSetting(s Software, includes []string, _ string) FormattedRow { + r := baseRow(s) + if contains(includes, "available_versions") { + vals := optionValues(s) + sort.Strings(vals) + r.AvailableVersions = strings.Join(vals, ",") + } + return r +} + +// FormatSettingJSON formats for JSON output (available_versions as unsorted []string). +func FormatSettingJSON(s Software, includes []string) FormattedRow { + r := baseRow(s) + if contains(includes, "available_versions") { + r.AvailableVersions = optionValues(s) + } + return r +} + +func contains(ss []string, v string) bool { + for _, s := range ss { + if s == v { + return true + } + } + return false +} + +// componentNames maps slug → display name, mirroring Node's +// getComponentDisplayName (software.ts). +var componentNames = map[string]string{ + "wordpress": "WordPress", + "php": "PHP", + "muplugins": "MU Plugins", + "nodejs": "Node.js", +} + +// ComponentDisplayName returns the human-readable name for a component slug. +func ComponentDisplayName(slug string) string { return componentNames[slug] } + +// ValidComponents mirrors _processComponent (software.ts:225): WordPress app +// types {2,6} → wordpress,php,muplugins ; Node.js {3,5,7,8} → nodejs. +func ValidComponents(appTypeID int64) []string { + switch appTypeID { + case 2, 6: + return []string{"wordpress", "php", "muplugins"} + case 3, 5, 7, 8: + return []string{"nodejs"} + default: + return nil + } +} + +// ValidationError carries a Node-parity user-facing message. +type ValidationError struct{ Msg string } + +func (e *ValidationError) Error() string { return e.Msg } + +// ResolveComponent validates a user-provided component against the app type. +func ResolveComponent(appTypeID int64, component string) (string, error) { + valid := ValidComponents(appTypeID) + if len(valid) == 0 { + return "", &ValidationError{"No components are supported for this application"} + } + if component == "" { + if len(valid) == 1 { + return valid[0], nil + } + return "", &ValidationError{"Please specify a component: " + strings.Join(valid, ",")} + } + if !contains(valid, component) { + return "", &ValidationError{"Component " + component + " is not supported. Use one of: " + strings.Join(valid, ",")} + } + return component, nil +} + +// AllowedVersions returns the version values shown by `config software get` +// (deprecated excluded, per software.ts:439). +func AllowedVersions(s Software) []string { return optionValues(s) } + +// UpdatableVersions returns the versions `config software update` accepts — +// Node's _optionsForVersion values, deprecated INCLUDED (software.ts:275-282). +// Deprecated builds are precisely what an incident responder rolls back to, +// so they must stay selectable even though they are hidden from `get`. +func UpdatableVersions(s Software) []string { return allOptionValues(s) } + +// ResolveVersion validates a user-provided version against the set Node's +// _processComponentVersion accepts (software.ts:275). The "Use one of:" list +// is built from the same values, so it advertises deprecated versions too. +func ResolveVersion(s Software, component, version string) (string, error) { + allowed := UpdatableVersions(s) + if !contains(allowed, version) { + return "", &ValidationError{"Version " + version + " is not supported for " + componentNames[component] + ". Use one of: " + strings.Join(allowed, ",")} + } + return version, nil +} diff --git a/internal/softwaresettings/format_test.go b/internal/softwaresettings/format_test.go new file mode 100644 index 000000000..853baee21 --- /dev/null +++ b/internal/softwaresettings/format_test.go @@ -0,0 +1,123 @@ +package softwaresettings + +import ( + "reflect" + "strings" + "testing" +) + +func wpSetting() Software { + return Software{ + Name: "WordPress", Slug: "wordpress", Pinned: false, + Current: Version{Version: "6.4"}, + Options: []Version{{Version: "6.3"}, {Version: "6.4"}, {Version: "6.5", Unstable: true}, {Version: "5.9", Deprecated: true}}, + } +} + +func TestFormatManagedUpdatesSuffix(t *testing.T) { + got := FormatSetting(wpSetting(), nil, "table") + if got.Version != "6.4 (managed updates)" { + t.Errorf("version = %q", got.Version) + } +} + +func TestFormatAvailableVersionsNonJSONSortedJoined(t *testing.T) { + got := FormatSetting(wpSetting(), []string{"available_versions"}, "table") + if got.AvailableVersions != "6.3,6.4,6.5,managed_latest" { + t.Errorf("available = %q", got.AvailableVersions) + } +} + +func TestFormatAvailableVersionsJSONArray(t *testing.T) { + got := FormatSettingJSON(wpSetting(), []string{"available_versions"}) + want := []string{"managed_latest", "6.3", "6.4", "6.5"} // managed → supported(option order) → test + if !reflect.DeepEqual(got.AvailableVersions, want) { + t.Errorf("available = %v want %v", got.AvailableVersions, want) + } +} + +func TestValidComponentsForAppType(t *testing.T) { + if got := ValidComponents(2); !reflect.DeepEqual(got, []string{"wordpress", "php", "muplugins"}) { + t.Errorf("wp components = %v", got) + } + if got := ValidComponents(6); !reflect.DeepEqual(got, []string{"wordpress", "php", "muplugins"}) { + t.Errorf("wp-nonprod components = %v", got) + } + if got := ValidComponents(3); !reflect.DeepEqual(got, []string{"nodejs"}) { + t.Errorf("node components = %v", got) + } +} + +func TestResolveComponentRejectsUnsupported(t *testing.T) { + _, err := ResolveComponent(2, "nodejs") + if err == nil || err.Error() != "Component nodejs is not supported. Use one of: wordpress,php,muplugins" { + t.Errorf("err = %v", err) + } +} + +func TestResolveVersionRejectsUnsupported(t *testing.T) { + _, err := ResolveVersion(wpSetting(), "wordpress", "9.9") + if err == nil || !strings.Contains(err.Error(), "Version 9.9 is not supported for WordPress. Use one of:") { + t.Errorf("err = %v", err) + } +} + +func TestResolveVersionAcceptsAllowed(t *testing.T) { + v, err := ResolveVersion(wpSetting(), "wordpress", "managed_latest") + if err != nil || v != "managed_latest" { + t.Errorf("v=%q err=%v", v, err) + } +} + +// Register 2.9. Node's _processComponentVersion (software.ts:275) validates +// against _optionsForVersion(), whose allOptions array is +// managed → supported → test → DEPRECATED (software.ts:204-209). Deprecated +// versions are therefore selectable for an update. Only the `config software +// get` display path filters them out (software.ts:439). Rejecting them in Go +// blocks the exact rollback a responder reaches for during an incident. +func TestResolveVersionAcceptsDeprecatedVersion(t *testing.T) { + v, err := ResolveVersion(wpSetting(), "wordpress", "5.9") + if err != nil { + t.Fatalf("ResolveVersion(5.9) = %v, want nil — Node permits deprecated versions for update", err) + } + if v != "5.9" { + t.Errorf("v = %q, want 5.9", v) + } +} + +// The "Use one of:" list Node prints is built from the same validValues, +// so it advertises deprecated versions too. +func TestResolveVersionErrorListsDeprecatedVersions(t *testing.T) { + _, err := ResolveVersion(wpSetting(), "wordpress", "9.9") + if err == nil { + t.Fatal("want error for 9.9") + } + if !strings.Contains(err.Error(), "5.9") { + t.Errorf("err = %q, want the deprecated 5.9 listed in the allowed set", err) + } +} + +// Node's option order — managed, supported (option-array order), test, +// deprecated last. +func TestUpdatableVersionsOrderMatchesNodeAllOptions(t *testing.T) { + got := UpdatableVersions(wpSetting()) + want := []string{"managed_latest", "6.3", "6.4", "6.5", "5.9"} + if !reflect.DeepEqual(got, want) { + t.Errorf("UpdatableVersions = %v, want %v", got, want) + } +} + +// Guard: broadening the UPDATE surface must not leak deprecated versions +// into `config software get`, which Node explicitly filters (software.ts:439). +func TestDisplayVersionsStillExcludeDeprecated(t *testing.T) { + got := FormatSettingJSON(wpSetting(), []string{"available_versions"}) + vals, ok := got.AvailableVersions.([]string) + if !ok { + t.Fatalf("AvailableVersions type = %T", got.AvailableVersions) + } + for _, v := range vals { + if v == "5.9" { + t.Errorf("deprecated 5.9 leaked into `config software get` output: %v", vals) + } + } +} From a22e44c95f0bded2572c691a1214db024261577c Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 20 Aug 2026 18:36:40 -0500 Subject: [PATCH 10/32] feat(go): SQL export, validation and site import Ported from vip-cli-golang@72ef2f89 (feature/go-rewrite). --- internal/searchreplace/dumpdetails.go | 104 ++++ internal/searchreplace/dumpdetails_test.go | 86 ++++ internal/searchreplace/searchreplace.go | 247 +++++++++ internal/searchreplace/searchreplace_test.go | 250 +++++++++ internal/siteimport/siteimport.go | 18 + internal/siteimport/siteimport_test.go | 19 + internal/siteimport/sitetype.go | 74 +++ internal/siteimport/sitetype_test.go | 62 +++ internal/siteimport/status.go | 271 ++++++++++ internal/siteimport/status_test.go | 234 +++++++++ internal/sqlexport/diskspace.go | 36 ++ internal/sqlexport/diskspace_unix.go | 24 + internal/sqlexport/diskspace_windows.go | 26 + internal/sqlexport/download.go | 75 +++ internal/sqlexport/download_test.go | 103 ++++ internal/sqlexport/export.go | 321 ++++++++++++ internal/sqlexport/export_test.go | 313 ++++++++++++ internal/sqlexport/format.go | 44 ++ internal/sqlexport/livecopy.go | 150 ++++++ internal/sqlexport/livecopy_test.go | 273 ++++++++++ internal/sqlvalidation/devenv_checks_test.go | 134 +++++ internal/sqlvalidation/filename.go | 30 ++ internal/sqlvalidation/line_by_line.go | 91 ++++ internal/sqlvalidation/line_by_line_test.go | 165 ++++++ internal/sqlvalidation/multisite.go | 31 ++ internal/sqlvalidation/multisite_test.go | 34 ++ internal/sqlvalidation/sql.go | 510 +++++++++++++++++++ internal/sqlvalidation/sql_test.go | 206 ++++++++ 28 files changed, 3931 insertions(+) create mode 100644 internal/searchreplace/dumpdetails.go create mode 100644 internal/searchreplace/dumpdetails_test.go create mode 100644 internal/searchreplace/searchreplace.go create mode 100644 internal/searchreplace/searchreplace_test.go create mode 100644 internal/siteimport/siteimport.go create mode 100644 internal/siteimport/siteimport_test.go create mode 100644 internal/siteimport/sitetype.go create mode 100644 internal/siteimport/sitetype_test.go create mode 100644 internal/siteimport/status.go create mode 100644 internal/siteimport/status_test.go create mode 100644 internal/sqlexport/diskspace.go create mode 100644 internal/sqlexport/diskspace_unix.go create mode 100644 internal/sqlexport/diskspace_windows.go create mode 100644 internal/sqlexport/download.go create mode 100644 internal/sqlexport/download_test.go create mode 100644 internal/sqlexport/export.go create mode 100644 internal/sqlexport/export_test.go create mode 100644 internal/sqlexport/format.go create mode 100644 internal/sqlexport/livecopy.go create mode 100644 internal/sqlexport/livecopy_test.go create mode 100644 internal/sqlvalidation/devenv_checks_test.go create mode 100644 internal/sqlvalidation/filename.go create mode 100644 internal/sqlvalidation/line_by_line.go create mode 100644 internal/sqlvalidation/line_by_line_test.go create mode 100644 internal/sqlvalidation/multisite.go create mode 100644 internal/sqlvalidation/multisite_test.go create mode 100644 internal/sqlvalidation/sql.go create mode 100644 internal/sqlvalidation/sql_test.go diff --git a/internal/searchreplace/dumpdetails.go b/internal/searchreplace/dumpdetails.go new file mode 100644 index 000000000..10741f0ad --- /dev/null +++ b/internal/searchreplace/dumpdetails.go @@ -0,0 +1,104 @@ +// Package searchreplace ports src/lib/search-and-replace.ts by shelling +// out to the existing Go `go-search-replace` binary (design §7.3 — NOT a +// reimplementation), plus the SQL-dump-type sniffing from +// src/lib/database.ts that the replace pipeline depends on. +package searchreplace + +import ( + "bufio" + "compress/gzip" + "io" + "os" + "regexp" + "strings" +) + +// DumpType mirrors Node's SqlDumpType enum (database.ts:8). +type DumpType string + +const ( + DumpTypeMyDumper DumpType = "MYDUMPER" + DumpTypeMysqldump DumpType = "MYSQLDUMP" +) + +// DumpDetails mirrors SqlDumpDetails (database.ts:13). +type DumpDetails struct { + Type DumpType + SourceDB string +} + +var ( + // database.ts:44 + metadataHeaderRE = regexp.MustCompile(`^-- metadata\.header `) + // database.ts:46 + sourceDBRE = regexp.MustCompile(`^-- (.*)-schema-create\.sql`) + // fixMyDumperRE — database.ts:110. + fixMyDumperRE = regexp.MustCompile(`^-- ([^ ]+) \d+$`) +) + +// GetSqlDumpDetails ports getSqlDumpDetails (database.ts:18): scan up to +// the first ~100 non-empty lines for mydumper markers. Transparent .gz +// support (suffix-based, like Node). +func GetSqlDumpDetails(filePath string) (DumpDetails, error) { + f, err := os.Open(filePath) // #nosec G304 -- caller-supplied CLI path + if err != nil { + return DumpDetails{}, err + } + defer f.Close() + + var r io.Reader = f + if strings.HasSuffix(filePath, ".gz") { + zr, err := gzip.NewReader(f) + if err != nil { + return DumpDetails{}, err + } + defer zr.Close() + r = zr + } + + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + + isMyDumper := false + sourceDB := "" + lineNo := 0 + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + if metadataHeaderRE.MatchString(line) { + isMyDumper = true + } + if m := sourceDBRE.FindStringSubmatch(line); m != nil && sourceDB == "" { + sourceDB = m[1] + } + if isMyDumper && sourceDB != "" { + // all fields found? end the search early (database.ts:57) + break + } + if lineNo > 100 { + // database.ts:62 — assume not mydumper past the 100th line + break + } + lineNo++ + } + if err := scanner.Err(); err != nil { + return DumpDetails{}, err + } + typ := DumpTypeMysqldump + if isMyDumper { + typ = DumpTypeMyDumper + } + return DumpDetails{Type: typ, SourceDB: sourceDB}, nil +} + +// FixMyDumperLine ports fixMyDumperTransform's per-line rewrite +// (database.ts:109): `-- ` becomes `--
-1`. +func FixMyDumperLine(line string) string { + m := fixMyDumperRE.FindStringSubmatch(line) + if m == nil { + return line + } + return "-- " + m[1] + " -1" +} diff --git a/internal/searchreplace/dumpdetails_test.go b/internal/searchreplace/dumpdetails_test.go new file mode 100644 index 000000000..e158b16b2 --- /dev/null +++ b/internal/searchreplace/dumpdetails_test.go @@ -0,0 +1,86 @@ +package searchreplace + +import ( + "compress/gzip" + "os" + "path/filepath" + "strings" + "testing" +) + +func write(t *testing.T, name, content string) string { + t.Helper() + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func TestGetSqlDumpDetailsMyDumper(t *testing.T) { + p := write(t, "d.sql", "-- metadata.header 1\n-- mydb-schema-create.sql 0\nSELECT 1;\n") + d, err := GetSqlDumpDetails(p) + if err != nil { + t.Fatal(err) + } + if d.Type != DumpTypeMyDumper || d.SourceDB != "mydb" { + t.Errorf("details = %+v", d) + } +} + +func TestGetSqlDumpDetailsMysqldump(t *testing.T) { + p := write(t, "d.sql", "-- MySQL dump 10.13\nCREATE TABLE wp_posts;\n") + d, err := GetSqlDumpDetails(p) + if err != nil { + t.Fatal(err) + } + if d.Type != DumpTypeMysqldump { + t.Errorf("details = %+v", d) + } +} + +func TestGetSqlDumpDetailsStopsAt100Lines(t *testing.T) { + content := strings.Repeat("SELECT 1;\n", 150) + "-- metadata.header 1\n" + p := write(t, "d.sql", content) + d, err := GetSqlDumpDetails(p) + if err != nil { + t.Fatal(err) + } + if d.Type != DumpTypeMysqldump { + t.Error("metadata.header after line 100 must not flip the type (database.ts:62)") + } +} + +func TestGetSqlDumpDetailsGz(t *testing.T) { + p := filepath.Join(t.TempDir(), "d.sql.gz") + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + zw := gzip.NewWriter(f) + if _, err := zw.Write([]byte("-- metadata.header 1\n-- gzdb-schema-create.sql 0\n")); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + d, err := GetSqlDumpDetails(p) + if err != nil { + t.Fatal(err) + } + if d.Type != DumpTypeMyDumper || d.SourceDB != "gzdb" { + t.Errorf("details = %+v", d) + } +} + +func TestFixMyDumperLine(t *testing.T) { + if got := FixMyDumperLine("-- wp_posts 12345"); got != "-- wp_posts -1" { + t.Errorf("got %q", got) + } + if got := FixMyDumperLine("INSERT INTO wp_posts VALUES (1);"); got != "INSERT INTO wp_posts VALUES (1);" { + t.Errorf("non-matching line altered: %q", got) + } +} diff --git a/internal/searchreplace/searchreplace.go b/internal/searchreplace/searchreplace.go new file mode 100644 index 000000000..7a4ba9d49 --- /dev/null +++ b/internal/searchreplace/searchreplace.go @@ -0,0 +1,247 @@ +package searchreplace + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// InPlaceConfirmMessage is the prompt Node shows before an irreversible +// in-place rewrite (search-and-replace.ts:152-155). Node's enquirer confirm +// defaults to No, so callers must pass defaultYes=false. +const InPlaceConfirmMessage = "Are you sure you want to run search and replace on your input file? This operation is not reversible." + +// Options mirror SearchReplaceOptions (search-and-replace.ts:101). +// +// The in-place confirm is the CALLER's job, mirroring Node's batchMode gate +// (`inPlace && !batchMode`, ts:151). Which callers prompt is NOT uniform, so +// check the Node source before adding or removing one: +// +// - platform `vip import sql` passes batchMode:true (vip-import-sql.js:732) +// and must NOT prompt — the command has already confirmed. +// - standalone `vip search-replace` passes no batchMode +// (vip-search-replace.js:74) and DOES prompt. +// - `vip dev-env import sql` reaches this through resolveImportPath with no +// batchMode (dev-environment-core.ts:854) and DOES prompt. +type Options struct { + InPlace bool + Output string // non-empty => write to this path; empty + !InPlace => temp file +} + +// Result mirrors SearchReplaceOutput (search-and-replace.ts:108). +type Result struct { + InputFileName string + OutputFileName string + UsingStdOut bool // always false in M7a (import path never streams to stdout) +} + +// ResolveBinary finds go-search-replace per design §7.3: +// $VIP_SEARCH_REPLACE_BIN → /bin/go-search-replace[.exe] +// → /go-search-replace[.exe] (sibling — where `make build` +// drops the bundled binary next to bin/vip-next) → PATH. +func ResolveBinary() (string, error) { + if p := os.Getenv("VIP_SEARCH_REPLACE_BIN"); p != "" { + return p, nil + } + name := "go-search-replace" + if runtime.GOOS == "windows" { + name += ".exe" + } + if exe, err := os.Executable(); err == nil { + if p, ok := lookupBundled(exe, name); ok { + return p, nil + } + } + if p, err := exec.LookPath(name); err == nil { + return p, nil + } + return "", errors.New("unable to locate the go-search-replace binary; set VIP_SEARCH_REPLACE_BIN or add go-search-replace to PATH") +} + +// lookupBundled resolves any symlink on exePath (vip-next is commonly run via a +// PATH symlink like ~/.local/bin/vip-next, and os.Executable() returns the +// symlink, not its target, on macOS), then looks for under /bin/ +// (release-tarball layout) or / (sibling — where `make build` drops it). +func lookupBundled(exePath, name string) (string, bool) { + if resolved, err := filepath.EvalSymlinks(exePath); err == nil { + exePath = resolved + } + dir := filepath.Dir(exePath) + for _, cand := range []string{filepath.Join(dir, "bin", name), filepath.Join(dir, name)} { + if statExists(cand) { + return cand, true + } + } + return "", false +} + +func statExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// Run ports searchAndReplace (search-and-replace.ts:114) minus prompts and +// telemetry (caller's job): determine replacements, wire input/output +// files, stream input → go-search-replace → (optional mydumper fix) → +// output. +func Run(fileName string, pairs []string, opts Options) (*Result, error) { + // Node: if (!pairs.length) throw (ts:138) + if len(pairs) == 0 { + return nil, errors.New("No search and replace parameters provided.") + } + details, err := GetSqlDumpDetails(fileName) + if err != nil { + return nil, err + } + + // Node: pairs.flatMap(pair => pair.split(',').map(trim)) (ts:148) + var replacements []string + for _, pair := range pairs { + for _, part := range strings.Split(pair, ",") { + replacements = append(replacements, strings.TrimSpace(part)) + } + } + + inputPath := fileName + outputPath := opts.Output + if opts.InPlace { + // Node copies the input to a temp "midput" file first (ts:40-58) because + // it opens a write stream on the original immediately. We instead stage + // the result in a sibling temp file and rename it into place only on + // success (see below), so the original is never truncated and can be + // read directly — no full extra copy of a multi-GB dump. + outputPath = fileName + } else if outputPath == "" { + // Default: temp output file keeping the basename (ts:79-90). + tmpDir, err := os.MkdirTemp("", "vip-search-replace") + if err != nil { + return nil, err + } + outputPath = filepath.Join(tmpDir, filepath.Base(fileName)) + } + + bin, err := ResolveBinary() + if err != nil { + return nil, err + } + + in, err := os.Open(inputPath) // #nosec G304 + if err != nil { + return nil, err + } + defer in.Close() + + // Stage the result in a temp file beside the target and rename it into place + // only after go-search-replace exits cleanly. Opening the target directly + // (os.Create) truncates it before the child's result is known, so a rejected + // search-replace pair left the user with a 0-byte file — and under + // --in-place that file is their own dump (parity blocker B2). The temp sits + // in the target's directory so the rename is same-filesystem, hence atomic; + // every failure path below removes it. + tmpPath, out, err := createTempBeside(outputPath) + if err != nil { + return nil, err + } + committed := false + defer func() { + if !committed { + _ = out.Close() + _ = os.Remove(tmpPath) + } + }() + + cmd := exec.Command(bin, replacements...) // #nosec G204 -- resolved binary + user-supplied pairs + cmd.Stdin = in + cmd.Stderr = os.Stderr + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, err + } + + if details.Type == DumpTypeMyDumper { + err = pipeFixingMyDumper(stdout, out) + } else { + _, err = io.Copy(out, stdout) + } + if err != nil { + _ = cmd.Wait() + return nil, fmt.Errorf("couldn't write to the output file: %w", err) + } + if err := cmd.Wait(); err != nil { + return nil, err + } + if err := out.Close(); err != nil { + return nil, err + } + if err := os.Rename(tmpPath, outputPath); err != nil { + return nil, err + } + committed = true + + return &Result{InputFileName: fileName, OutputFileName: outputPath}, nil +} + +// createTempBeside opens a uniquely named temp file in target's directory, so a +// later os.Rename onto target stays on one filesystem (cross-device renames +// fail). When target already exists the temp is chmod'ed to match it, so an +// atomic replace never silently widens or narrows the file's permissions; for a +// new target the 0666 open mode reproduces os.Create's umask-respecting default. +func createTempBeside(target string) (string, *os.File, error) { + dir := filepath.Dir(target) + perm, hadTarget := os.FileMode(0), false + if st, err := os.Stat(target); err == nil { + perm, hadTarget = st.Mode().Perm(), true + } + for attempt := 0; attempt < 100; attempt++ { + var buf [8]byte + if _, err := rand.Read(buf[:]); err != nil { + return "", nil, err + } + p := filepath.Join(dir, "."+filepath.Base(target)+".vip-sr-"+hex.EncodeToString(buf[:])) + f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o666) // #nosec G304 + if errors.Is(err, os.ErrExist) { + continue + } + if err != nil { + return "", nil, err + } + if hadTarget { + if err := f.Chmod(perm); err != nil { + _ = f.Close() + _ = os.Remove(p) + return "", nil, err + } + } + return p, f, nil + } + return "", nil, errors.New("unable to create a temporary file next to " + target) +} + +// pipeFixingMyDumper streams r to w applying FixMyDumperLine per line — +// Node's fixMyDumperTransform stage in the pipeline (ts:184). +func pipeFixingMyDumper(r io.Reader, w io.Writer) error { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + bw := bufio.NewWriter(w) + for scanner.Scan() { + if _, err := bw.WriteString(FixMyDumperLine(scanner.Text()) + "\n"); err != nil { + return err + } + } + if err := scanner.Err(); err != nil { + return err + } + return bw.Flush() +} diff --git a/internal/searchreplace/searchreplace_test.go b/internal/searchreplace/searchreplace_test.go new file mode 100644 index 000000000..96343905a --- /dev/null +++ b/internal/searchreplace/searchreplace_test.go @@ -0,0 +1,250 @@ +package searchreplace + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// When vip-next is invoked via a symlink (e.g. ~/.local/bin/vip-next -> +// repo/bin/vip-next), os.Executable() returns the symlink path on macOS, so the +// bundled go-search-replace next to the REAL binary must still be found. +func TestLookupBundledFollowsSymlink(t *testing.T) { + root := t.TempDir() + realDir := filepath.Join(root, "repo", "bin") + if err := os.MkdirAll(realDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(realDir, "vip-next"), []byte("x"), 0o755); err != nil { // #nosec G306 + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(realDir, "go-search-replace"), []byte("x"), 0o755); err != nil { // #nosec G306 + t.Fatal(err) + } + linkDir := filepath.Join(root, "link") + if err := os.MkdirAll(linkDir, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(linkDir, "vip-next") + if err := os.Symlink(filepath.Join(realDir, "vip-next"), link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + got, ok := lookupBundled(link, "go-search-replace") + if !ok { + t.Fatal("expected to resolve sibling go-search-replace via the symlink's real dir") + } + if filepath.Base(got) != "go-search-replace" || !statExists(got) { + t.Fatalf("lookupBundled returned %q", got) + } +} + +// fakeBinary writes a script that upper-cases stdin (stand-in for +// go-search-replace; we assert plumbing, not replacement logic). +func fakeBinary(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake binary script is POSIX-only") + } + dir := t.TempDir() + p := filepath.Join(dir, "go-search-replace") + script := "#!/bin/sh\ntr 'a-z' 'A-Z'\n" + if err := os.WriteFile(p, []byte(script), 0o755); err != nil { // #nosec G306 -- executable test script + t.Fatal(err) + } + return p +} + +func TestResolveBinaryEnvVarFirst(t *testing.T) { + bin := fakeBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + got, err := ResolveBinary() + if err != nil { + t.Fatal(err) + } + if got != bin { + t.Errorf("got %q want %q", got, bin) + } +} + +func TestResolveBinaryFromPath(t *testing.T) { + bin := fakeBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", "") + t.Setenv("PATH", filepath.Dir(bin)) + got, err := ResolveBinary() + if err != nil { + t.Fatal(err) + } + if got != bin { + t.Errorf("got %q want %q", got, bin) + } +} + +func TestResolveBinaryMissing(t *testing.T) { + t.Setenv("VIP_SEARCH_REPLACE_BIN", "") + t.Setenv("PATH", t.TempDir()) // nothing on PATH + if _, err := ResolveBinary(); err == nil { + t.Error("want error when binary is nowhere") + } +} + +func TestRunToOutputFile(t *testing.T) { + bin := fakeBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + in := write(t, "in.sql", "hello world\n") + out := filepath.Join(t.TempDir(), "out.sql") + + res, err := Run(in, []string{"from,to"}, Options{Output: out}) + if err != nil { + t.Fatal(err) + } + if res.OutputFileName != out { + t.Errorf("OutputFileName = %q", res.OutputFileName) + } + got, _ := os.ReadFile(out) // #nosec G304 + if strings.TrimSpace(string(got)) != "HELLO WORLD" { + t.Errorf("output = %q", got) + } + if res.UsingStdOut { + t.Error("UsingStdOut should be false") + } +} + +func TestRunInPlace(t *testing.T) { + bin := fakeBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + in := write(t, "in.sql", "abc\n") + + res, err := Run(in, []string{"a,b"}, Options{InPlace: true}) + if err != nil { + t.Fatal(err) + } + if res.OutputFileName != in { + t.Errorf("in-place must overwrite the input; got %q", res.OutputFileName) + } + got, _ := os.ReadFile(in) // #nosec G304 + if strings.TrimSpace(string(got)) != "ABC" { + t.Errorf("content = %q", got) + } +} + +func TestRunDefaultTempOutput(t *testing.T) { + bin := fakeBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + in := write(t, "in.sql", "q\n") + + res, err := Run(in, []string{"x,y"}, Options{}) + if err != nil { + t.Fatal(err) + } + if res.OutputFileName == "" || res.OutputFileName == in { + t.Errorf("default mode must write a temp copy, got %q", res.OutputFileName) + } + if filepath.Base(res.OutputFileName) != "in.sql" { + t.Errorf("temp file keeps the basename; got %q", res.OutputFileName) + } +} + +func TestRunMyDumperFixApplied(t *testing.T) { + bin := fakeBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + in := write(t, "in.sql", "-- metadata.header 1\n-- mydb-schema-create.sql 0\n-- wp_posts 123\n") + out := filepath.Join(t.TempDir(), "out.sql") + + if _, err := Run(in, []string{"a,b"}, Options{Output: out}); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(out) // #nosec G304 + // tr upper-cases first, then the mydumper fix runs on the binary's + // output: "-- WP_POSTS 123" matches the rewrite pattern. + if !strings.Contains(string(got), "-- WP_POSTS -1") { + t.Errorf("mydumper fix not applied: %q", got) + } +} + +func TestRunNoPairs(t *testing.T) { + in := write(t, "in.sql", "q\n") + if _, err := Run(in, nil, Options{}); err == nil || + err.Error() != "No search and replace parameters provided." { + t.Errorf("err = %v", err) + } +} + +// failingBinary stands in for go-search-replace rejecting a search-replace +// pair: it writes nothing to stdout and exits non-zero. +func failingBinary(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake binary script is POSIX-only") + } + dir := t.TempDir() + p := filepath.Join(dir, "go-search-replace") + script := "#!/bin/sh\necho 'invalid search-replace pair' >&2\nexit 1\n" + if err := os.WriteFile(p, []byte(script), 0o755); err != nil { // #nosec G306 -- executable test script + t.Fatal(err) + } + return p +} + +// Regression for parity blocker B2: `--in-place` used to os.Create() the user's +// own file, truncating it BEFORE the child's result was known, so a rejected +// pair left a 0-byte file. Asserting the exit code alone would not catch this — +// assert the original bytes survive. +func TestRunInPlaceKeepsOriginalBytesWhenChildFails(t *testing.T) { + bin := failingBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + const original = "CREATE TABLE a;\n" + in := write(t, "in.sql", original) + + if _, err := Run(in, []string{"from,to"}, Options{InPlace: true}); err == nil { + t.Fatal("expected an error when go-search-replace fails") + } + + got, err := os.ReadFile(in) // #nosec G304 + if err != nil { + t.Fatalf("in-place input file must still exist after a failure: %v", err) + } + if string(got) != original { + t.Errorf("in-place input was destroyed by a failed run:\n got %q\nwant %q", got, original) + } +} + +// A failed run to an explicit --output must not leave a truncated artifact +// behind that a later step could mistake for a real dump. +func TestRunOutputFileNotLeftTruncatedWhenChildFails(t *testing.T) { + bin := failingBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + in := write(t, "in.sql", "CREATE TABLE a;\n") + out := filepath.Join(t.TempDir(), "out.sql") + + if _, err := Run(in, []string{"from,to"}, Options{Output: out}); err == nil { + t.Fatal("expected an error when go-search-replace fails") + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + b, _ := os.ReadFile(out) // #nosec G304 + t.Errorf("failed run left an output file behind (%d bytes: %q)", len(b), b) + } +} + +// The atomic rename must not silently widen or narrow the file's permissions. +func TestRunInPlacePreservesFileMode(t *testing.T) { + bin := fakeBinary(t) + t.Setenv("VIP_SEARCH_REPLACE_BIN", bin) + in := write(t, "in.sql", "abc\n") + if err := os.Chmod(in, 0o640); err != nil { + t.Fatal(err) + } + + if _, err := Run(in, []string{"a,b"}, Options{InPlace: true}); err != nil { + t.Fatal(err) + } + st, err := os.Stat(in) + if err != nil { + t.Fatal(err) + } + if got := st.Mode().Perm(); got != 0o640 { + t.Errorf("mode = %o, want 640", got) + } +} diff --git a/internal/siteimport/siteimport.go b/internal/siteimport/siteimport.go new file mode 100644 index 000000000..5cece2d1b --- /dev/null +++ b/internal/siteimport/siteimport.go @@ -0,0 +1,18 @@ +// Package siteimport ports src/lib/site-import/** plus the site-type / +// multisite-domain validations that gate `vip import sql`. +package siteimport + +const gbInBytes = int64(1024 * 1024 * 1024) + +// Node src/lib/site-import/db-file-import.ts:5-6. +const ( + SQLImportFileSizeLimit = 200 * gbInBytes + SQLImportFileSizeLimitLaunched = 10 * gbInBytes +) + +// databaseApplicationTypeIDs — src/lib/constants/vipgo.ts:19 +// [WORDPRESS=2, WORDPRESS_NON_PROD=6, NODEJS_MYSQL=5, NODEJS_MYSQL_REDIS=8]. +var databaseApplicationTypeIDs = map[int64]bool{2: true, 6: true, 5: true, 8: true} + +// IsSupportedApp ports isSupportedApp (db-file-import.ts:25). +func IsSupportedApp(typeID int64) bool { return databaseApplicationTypeIDs[typeID] } diff --git a/internal/siteimport/siteimport_test.go b/internal/siteimport/siteimport_test.go new file mode 100644 index 000000000..47d15ce09 --- /dev/null +++ b/internal/siteimport/siteimport_test.go @@ -0,0 +1,19 @@ +package siteimport + +import "testing" + +func TestIsSupportedApp(t *testing.T) { + // DATABASE_APPLICATION_TYPE_IDS = [2, 6, 5, 8] (src/lib/constants/vipgo.ts:19) + for id, want := range map[int64]bool{2: true, 6: true, 5: true, 8: true, 3: false, 0: false} { + if got := IsSupportedApp(id); got != want { + t.Errorf("IsSupportedApp(%d) = %v", id, got) + } + } +} + +func TestSizeLimits(t *testing.T) { + const gb = int64(1024 * 1024 * 1024) + if SQLImportFileSizeLimit != 200*gb || SQLImportFileSizeLimitLaunched != 10*gb { + t.Errorf("limits = %d / %d", SQLImportFileSizeLimit, SQLImportFileSizeLimitLaunched) + } +} diff --git a/internal/siteimport/sitetype.go b/internal/siteimport/sitetype.go new file mode 100644 index 000000000..2f02ff607 --- /dev/null +++ b/internal/siteimport/sitetype.go @@ -0,0 +1,74 @@ +package siteimport + +import ( + "regexp" + "strings" +) + +// MultilineCapture ports getMultilineStatement (validations/utils.ts:7): +// captures statements that start with a line matching the pattern and +// run until a line ending in ';'. +type MultilineCapture struct { + re *regexp.Regexp + capturing bool + statements [][]string +} + +// NewMultilineCapture compiles a literal start-of-statement pattern (the +// only caller uses "INSERT INTO `wp_site`" — site-type.ts:18). +func NewMultilineCapture(pattern string) *MultilineCapture { + return &MultilineCapture{re: regexp.MustCompile(regexp.QuoteMeta(pattern))} +} + +// Feed processes one line and returns the statements captured so far. +// Each statement is the list of its lines, like Node's string[][]. +func (m *MultilineCapture) Feed(line string) [][]string { + start := m.re.MatchString(line) + end := (start || m.capturing) && strings.HasSuffix(line, ";") + if start { + m.capturing = true + m.statements = append(m.statements, nil) + } + if m.capturing { + idx := len(m.statements) - 1 + m.statements[idx] = append(m.statements[idx], line) + } + if end { + m.capturing = false + } + return m.statements +} + +var ( + // SQL_WP_SITE_DOMAINS_REGEX — is-multisite-domain-mapped.ts:23. + wpSiteDomainsRE = regexp.MustCompile(`\(1,'([^']+)'`) + whitespaceRE = regexp.MustCompile(`\s`) +) + +// GetPrimaryDomainFromSQL ports getPrimaryDomainFromSQL +// (is-multisite-domain-mapped.ts:18): extract the domain of blog ID 1 +// from the first captured INSERT INTO `wp_site` statement. +func GetPrimaryDomainFromSQL(statements [][]string) string { + if len(statements) == 0 { + return "" + } + normalized := whitespaceRE.ReplaceAllString(strings.Join(statements[0], ""), "") + if m := wpSiteDomainsRE.FindStringSubmatch(normalized); m != nil { + return m[1] + } + return "" +} + +// MaybeSearchReplacePrimaryDomain ports maybeSearchReplacePrimaryDomain +// (is-multisite-domain-mapped.ts:36). NOTE: Node splits on ',' WITHOUT +// trimming here (unlike the replacement list built for the binary) — +// kept bug-for-bug. +func MaybeSearchReplacePrimaryDomain(domain string, searchReplace []string) string { + for _, pair := range searchReplace { + parts := strings.Split(pair, ",") + if len(parts) >= 2 && parts[0] == domain { + return parts[1] + } + } + return domain +} diff --git a/internal/siteimport/sitetype_test.go b/internal/siteimport/sitetype_test.go new file mode 100644 index 000000000..6db466321 --- /dev/null +++ b/internal/siteimport/sitetype_test.go @@ -0,0 +1,62 @@ +package siteimport + +import "testing" + +func TestMultilineStatementCapture(t *testing.T) { + cap := NewMultilineCapture("INSERT INTO `wp_site`") + lines := []string{ + "CREATE TABLE `wp_site2` (id INT);", + "INSERT INTO `wp_site` (id, domain) VALUES", + "(1,'example.com','/');", + "SELECT 1;", + } + var stmts [][]string + for _, l := range lines { + stmts = cap.Feed(l) + } + if len(stmts) != 1 || len(stmts[0]) != 2 { + t.Fatalf("stmts = %v", stmts) + } +} + +func TestMultilineStatementCaptureSingleLine(t *testing.T) { + cap := NewMultilineCapture("INSERT INTO `wp_site`") + stmts := cap.Feed("INSERT INTO `wp_site` VALUES (1,'a.com','/');") + if len(stmts) != 1 || len(stmts[0]) != 1 { + t.Fatalf("stmts = %v", stmts) + } + // a second statement opens a new capture slot + stmts = cap.Feed("INSERT INTO `wp_site` VALUES (2,'b.com','/');") + if len(stmts) != 2 { + t.Fatalf("stmts = %v", stmts) + } +} + +func TestGetPrimaryDomainFromSQL(t *testing.T) { + stmts := [][]string{{ + "INSERT INTO `wp_site` (id, domain, path) VALUES", + "(1,'multisite.example.com','/');", + }} + if got := GetPrimaryDomainFromSQL(stmts); got != "multisite.example.com" { + t.Errorf("domain = %q", got) + } + if got := GetPrimaryDomainFromSQL(nil); got != "" { + t.Errorf("empty stmts should give %q, got %q", "", got) + } +} + +func TestMaybeSearchReplacePrimaryDomain(t *testing.T) { + got := MaybeSearchReplacePrimaryDomain("old.example.com", + []string{"other.com,new-other.com", "old.example.com,new.example.com"}) + if got != "new.example.com" { + t.Errorf("got %q", got) + } + if got := MaybeSearchReplacePrimaryDomain("keep.com", nil); got != "keep.com" { + t.Errorf("got %q", got) + } + // Node does NOT trim around the comma in this path — bug-for-bug. + got = MaybeSearchReplacePrimaryDomain("a.com", []string{"a.com, b.com"}) + if got != " b.com" { + t.Errorf("untrimmed replacement expected, got %q", got) + } +} diff --git a/internal/siteimport/status.go b/internal/siteimport/status.go new file mode 100644 index 000000000..3f0d6a2b0 --- /dev/null +++ b/internal/siteimport/status.go @@ -0,0 +1,271 @@ +package siteimport + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/fatih/color" + + "github.com/Automattic/vip/internal/tui" +) + +// DefaultPollInterval — IMPORT_SQL_PROGRESS_POLL_INTERVAL (status.ts:25). +const DefaultPollInterval = 5 * time.Second + +// JobStep is one step of the import job as reported by the server +// (jobs[].progress.steps or synthesized from importStatus.progress). +type JobStep struct { + ID string + Name string + Status tui.StepState +} + +// ImportJob mirrors the slice of Job the poller consumes (real k8s job or +// the pseudo-job Node synthesizes from importStatus.progress — +// status.ts:288-328; the synthesis lives in the command's fetch closure). +type ImportJob struct { + CreatedAt string + CompletedAt string + Status string // progress.status; "" treated as "unknown" (status.ts:333) + Steps []JobStep +} + +// FailedStep is a failed entry from importStatus.progress.steps +// (status.ts:361 failedImportStep). +type FailedStep struct { + Name string + Output []string + StartedAt int64 // unix seconds +} + +// ProgressSnapshot flattens one ImportSQLProgress response. Job == nil +// means "no job data available yet" — the poller waits (or fast-returns +// under ReturnMissingJobImmediately). +type ProgressSnapshot struct { + Job *ImportJob + StatusProgressStartedAt int64 // importStatus.progress.started_at (unix seconds) + FailedStep *FailedStep + Launched bool +} + +// ProgressFetch retrieves the current snapshot (the command wraps +// gql.ImportSQLProgress). +type ProgressFetch func(ctx context.Context) (*ProgressSnapshot, error) + +// CheckStatusOpts configures CheckStatus. +type CheckStatusOpts struct { + Fetch ProgressFetch + Tracker *tui.ProgressTracker + Interval time.Duration + // ReturnMissingJobImmediately — true for `vip import sql status` + // (status.ts:198). + ReturnMissingJobImmediately bool + // OnPoll fires after each snapshot is applied to the tracker, before + // terminal-state checks — the command renders its suffix block here. + OnPoll func(createdAt, completedAt, overallStatus string) +} + +// StatusResult is the terminal outcome of a finished (non-failed) poll. +type StatusResult struct { + Status string + Message string // e.g. "No import job found" + CreatedAt string + CompletedAt string +} + +// ImportFailedError ports ImportFailedError (status.ts:107). +type ImportFailedError struct { + InImportProgress bool + CommandOutput []string + ErrorText string + StepName string + Launched bool +} + +func (e *ImportFailedError) Error() string { return e.ErrorText } + +// parseFlexibleTime mimics JS `new Date(s).getTime()`: accept the formats +// the API and the synthesis path produce. Returns ok=false for NaN cases. +func parseFlexibleTime(s string) (time.Time, bool) { + for _, layout := range []string{ + time.RFC3339, time.RFC1123, time.RFC1123Z, time.RFC822, time.RFC850, + "2006-01-02 15:04:05", "2006-01-02T15:04:05.000Z", + } { + if t, err := time.Parse(layout, s); err == nil { + return t, true + } + } + return time.Time{}, false +} + +// CheckStatus ports importSqlCheckStatus's getResults loop +// (status.ts:267-417). The command owns rendering and exit codes; this +// owns the poll-state machine. +func CheckStatus(ctx context.Context, opts CheckStatusOpts) (*StatusResult, error) { + interval := opts.Interval + if interval == 0 { + interval = DefaultPollInterval + } + overall := "Checking..." // status.ts:213 + + for { + snap, err := opts.Fetch(ctx) + if err != nil { + return nil, err + } + + if snap.Job == nil { + if opts.ReturnMissingJobImmediately { + // status.ts:329 — resolve('No import job found') + return &StatusResult{Message: "No import job found"}, nil + } + // status.ts:294 — progress meta not filled out yet; wait. + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(interval): + } + continue + } + + job := snap.Job + jobStatus := job.Status + if jobStatus == "" { + jobStatus = "unknown" // status.ts:333 + } + createdAt := job.CreatedAt + completedAt := job.CompletedAt + + // failedImportStep gate (status.ts:353-366): the import_progress + // meta is only pertinent when it started at/after job creation. + var failed *FailedStep + if jobCreation, ok := parseFlexibleTime(createdAt); ok && + snap.StatusProgressStartedAt*1000 >= jobCreation.UnixMilli() { + if fs := snap.FailedStep; fs != nil && fs.StartedAt*1000 > jobCreation.UnixMilli() { + failed = fs + } + } + + if len(job.Steps) == 0 { + // status.ts:368 — reject({error: 'Could not enumerate the + // import job steps'}) + return nil, errors.New("Could not enumerate the import job steps") + } + + if failed != nil { + // status.ts:373 — demote the 'import' step to failed, render, + // then reject with the structured error. + steps := make([]JobStep, len(job.Steps)) + copy(steps, job.Steps) + for i := range steps { + if steps[i].ID == "import" { + steps[i].Status = tui.StepFailed + } + } + opts.Tracker.SetStepsFromServer(toServerSteps(steps)) + if opts.OnPoll != nil { + opts.OnPoll(createdAt, completedAt, "failed") + } + return nil, &ImportFailedError{ + InImportProgress: true, + CommandOutput: failed.Output, + ErrorText: "Import step failed", + StepName: failed.Name, + Launched: snap.Launched, + } + } + + opts.Tracker.SetStepsFromServer(toServerSteps(job.Steps)) + if opts.OnPoll != nil { + opts.OnPoll(createdAt, completedAt, overall) + } + + if jobStatus == "error" { + // status.ts:399 — reject({error: 'Import job failed', ...}) + return nil, errors.New("Import job failed") + } + + if jobStatus != "running" && completedAt != "" { + // status.ts:404 — resolve(importJob) + return &StatusResult{ + Status: jobStatus, CreatedAt: createdAt, CompletedAt: completedAt, + }, nil + } + + overall = "running" // status.ts:408 + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(interval): + } + } +} + +func toServerSteps(steps []JobStep) []tui.ServerStep { + out := make([]tui.ServerStep, len(steps)) + for i, s := range steps { + out[i] = tui.ServerStep{Name: s.Name, Status: s.Status} + } + return out +} + +// GetErrorMessage ports getErrorMessage (status.ts:116). Message blocks +// are copied verbatim, including blank lines and the conditional +// rollback notice (suppressed for launched environments). +func GetErrorMessage(fe *ImportFailedError) string { + rollbackMessage := "" + if !fe.Launched { + rollbackMessage = "Your site is " + color.BlueString("automatically being rolled back") + + " to the last backup prior to your import job.\n" + } + + message := fe.ErrorText + if !fe.InImportProgress { + return message + } + + commandOutputBlock := func() string { + if fe.CommandOutput != nil { + joined := strings.Join(fe.CommandOutput, ";") + return "\nPlease inspect your input file and make the appropriate corrections before trying again.\nThe server said:\n> " + + color.RedString(joined) + "\n" + } + return "" + } + + switch fe.StepName { + case "import_preflights": + message += "\nThis error occurred prior to the mysql batch script processing of your SQL file.\n\nYour site content was not altered.\n\nIf this error persists, please contact support.\n" + case "importing_db": + message += "\nThis error occurred during the mysql batch script processing of your SQL file.\n\n" + rollbackMessage + if fe.CommandOutput != nil { + message += commandOutputBlock() + } else { + message += "Please contact support and include this message along with your sql file." + } + case "validating_db": + message += "\nThis error occurred during the post-import validation of the imported data.\n\n" + rollbackMessage + "\n" + if fe.CommandOutput != nil { + message += commandOutputBlock() + } else { + message += "Please contact support and include this message along with your sql file." + } + case "update_primary_domain": + message += "\nThis error occurred during the update of the primary domain.\n\n" + rollbackMessage + "\n" + if fe.CommandOutput != nil { + message += commandOutputBlock() + } + } + return message +} + +// Capitalize ports format.ts capitalize (format.ts:139). +func Capitalize(s string) string { + if s == "" { + return "" + } + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/internal/siteimport/status_test.go b/internal/siteimport/status_test.go new file mode 100644 index 000000000..2b041d44a --- /dev/null +++ b/internal/siteimport/status_test.go @@ -0,0 +1,234 @@ +package siteimport + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Automattic/vip/internal/tui" +) + +// scriptedFetch returns each snapshot in order, repeating the last. +func scriptedFetch(snaps []ProgressSnapshot) ProgressFetch { + i := 0 + return func(ctx context.Context) (*ProgressSnapshot, error) { + s := snaps[i] + if i < len(snaps)-1 { + i++ + } + return &s, nil + } +} + +func TestCheckStatusSuccessFromJob(t *testing.T) { + created := "Mon, 01 Jun 2026 00:00:00 UTC" + completed := "Mon, 01 Jun 2026 00:05:00 UTC" + snaps := []ProgressSnapshot{ + {Job: &ImportJob{CreatedAt: created, Status: "running", Steps: []JobStep{ + {ID: "preflights", Name: "Import preflights", Status: tui.StepSuccess}, + {ID: "import", Name: "Importing db", Status: tui.StepRunning}, + }}}, + {Job: &ImportJob{CreatedAt: created, CompletedAt: completed, Status: "success", Steps: []JobStep{ + {ID: "preflights", Name: "Import preflights", Status: tui.StepSuccess}, + {ID: "import", Name: "Importing db", Status: tui.StepSuccess}, + }}}, + } + pt := tui.NewProgressTracker(nil) + var polls int + res, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scriptedFetch(snaps), Tracker: pt, Interval: time.Millisecond, + OnPoll: func(_, _, _ string) { polls++ }, + }) + if err != nil { + t.Fatal(err) + } + if res.Status != "success" || res.CompletedAt != completed { + t.Errorf("res = %+v", res) + } + if polls < 2 { + t.Errorf("OnPoll fired %d times, want >= 2", polls) + } + if !pt.AllStepsSucceeded() { + t.Error("tracker should reflect all-success server steps") + } +} + +func TestCheckStatusJobErrorRejects(t *testing.T) { + snaps := []ProgressSnapshot{ + {Job: &ImportJob{CreatedAt: "Mon, 01 Jun 2026 00:00:00 UTC", Status: "error", Steps: []JobStep{ + {ID: "import", Name: "Importing db", Status: tui.StepFailed}, + }}}, + } + pt := tui.NewProgressTracker(nil) + _, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scriptedFetch(snaps), Tracker: pt, Interval: time.Millisecond, + }) + if err == nil || !strings.Contains(err.Error(), "Import job failed") { + t.Errorf("err = %v", err) + } +} + +func TestCheckStatusMissingJobReturnsFast(t *testing.T) { + snaps := []ProgressSnapshot{{Job: nil}} + pt := tui.NewProgressTracker(nil) + res, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scriptedFetch(snaps), Tracker: pt, Interval: time.Millisecond, + ReturnMissingJobImmediately: true, + }) + if err != nil { + t.Fatal(err) + } + if res.Message != "No import job found" { + t.Errorf("message = %q", res.Message) + } +} + +func TestCheckStatusWaitsForProgressMeta(t *testing.T) { + created := "Mon, 01 Jun 2026 00:00:00 UTC" + snaps := []ProgressSnapshot{ + {Job: nil}, // meta not ready yet — must wait, not error + {Job: &ImportJob{CreatedAt: created, CompletedAt: created, Status: "success", Steps: []JobStep{ + {ID: "import", Name: "Importing db", Status: tui.StepSuccess}, + }}}, + } + pt := tui.NewProgressTracker(nil) + res, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scriptedFetch(snaps), Tracker: pt, Interval: time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + if res.Status != "success" { + t.Errorf("res = %+v", res) + } +} + +func TestCheckStatusEmptyStepsErrors(t *testing.T) { + snaps := []ProgressSnapshot{ + {Job: &ImportJob{CreatedAt: "Mon, 01 Jun 2026 00:00:00 UTC", Status: "running"}}, + } + pt := tui.NewProgressTracker(nil) + _, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scriptedFetch(snaps), Tracker: pt, Interval: time.Millisecond, + }) + if err == nil || err.Error() != "Could not enumerate the import job steps" { + t.Errorf("err = %v", err) + } +} + +func TestCheckStatusFailedImportStepProducesStepError(t *testing.T) { + now := time.Now() + snaps := []ProgressSnapshot{{ + Job: &ImportJob{ + CreatedAt: now.Add(-time.Hour).UTC().Format(time.RFC1123), + Status: "running", + Steps: []JobStep{ + {ID: "import", Name: "Import", Status: tui.StepRunning}, + }, + }, + StatusProgressStartedAt: now.Unix(), + FailedStep: &FailedStep{ + Name: "importing_db", Output: []string{"ERROR 1064 (42000) at line 9"}, + StartedAt: now.Unix(), + }, + Launched: false, + }} + pt := tui.NewProgressTracker(nil) + _, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scriptedFetch(snaps), Tracker: pt, Interval: time.Millisecond, + }) + var fe *ImportFailedError + if !errors.As(err, &fe) { + t.Fatalf("err = %v (type %T)", err, err) + } + if fe.StepName != "importing_db" || len(fe.CommandOutput) != 1 || !fe.InImportProgress { + t.Errorf("fe = %+v", fe) + } + // The demoted server step renders the failed glyph (Node sets + // hasFailure only for caller steps; server-step failure shows via the + // glyph — progress.ts:264). + if frame := pt.Frame(); !strings.Contains(frame, "✕") { + t.Errorf("frame missing failed glyph: %q", frame) + } +} + +func TestCheckStatusOldFailedStepIgnored(t *testing.T) { + // A failed step from BEFORE the job was created is stale and must be + // ignored (status.ts:353-366 timestamp gate). + now := time.Now() + created := now.UTC().Format(time.RFC1123) + snaps := []ProgressSnapshot{{ + Job: &ImportJob{ + CreatedAt: created, CompletedAt: created, Status: "success", + Steps: []JobStep{{ID: "import", Name: "Import", Status: tui.StepSuccess}}, + }, + StatusProgressStartedAt: now.Add(-2 * time.Hour).Unix(), + FailedStep: &FailedStep{ + Name: "importing_db", StartedAt: now.Add(-2 * time.Hour).Unix(), + }, + }} + pt := tui.NewProgressTracker(nil) + res, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scriptedFetch(snaps), Tracker: pt, Interval: time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + if res.Status != "success" { + t.Errorf("res = %+v", res) + } +} + +func TestGetErrorMessageBlocks(t *testing.T) { + fe := &ImportFailedError{ + InImportProgress: true, ErrorText: "Import step failed", + StepName: "importing_db", CommandOutput: []string{"line1", "line2"}, + Launched: false, + } + msg := GetErrorMessage(fe) + for _, want := range []string{ + "Import step failed", + "This error occurred during the mysql batch script processing of your SQL file.", + "automatically being rolled back", + "The server said:", + "line1;line2", + } { + if !strings.Contains(msg, want) { + t.Errorf("message missing %q:\n%s", want, msg) + } + } + + // launched suppresses the rollback notice + fe.Launched = true + if msg := GetErrorMessage(fe); strings.Contains(msg, "rolled back") { + t.Errorf("launched env must not mention rollback:\n%s", msg) + } + + // no command output → contact-support line + fe.CommandOutput = nil + if msg := GetErrorMessage(fe); !strings.Contains(msg, "Please contact support and include this message along with your sql file.") { + t.Errorf("missing contact-support fallback:\n%s", msg) + } + + // preflights block + fe2 := &ImportFailedError{InImportProgress: true, ErrorText: "Import step failed", StepName: "import_preflights"} + if msg := GetErrorMessage(fe2); !strings.Contains(msg, "Your site content was not altered.") { + t.Errorf("preflights block missing:\n%s", msg) + } + + // non-import-progress error returns the bare text + fe3 := &ImportFailedError{ErrorText: "Could not enumerate the import job steps"} + if msg := GetErrorMessage(fe3); msg != "Could not enumerate the import job steps" { + t.Errorf("msg = %q", msg) + } +} + +func TestCapitalize(t *testing.T) { + for in, want := range map[string]string{"": "", "import preflights": "Import preflights", "a": "A"} { + if got := Capitalize(in); got != want { + t.Errorf("Capitalize(%q) = %q", in, got) + } + } +} diff --git a/internal/sqlexport/diskspace.go b/internal/sqlexport/diskspace.go new file mode 100644 index 000000000..9b44ff787 --- /dev/null +++ b/internal/sqlexport/diskspace.go @@ -0,0 +1,36 @@ +package sqlexport + +import ( + "fmt" + "path/filepath" + + "github.com/Automattic/vip/internal/devenv/paths" +) + +// VipDataPath is the directory whose free space the storage check +// inspects (backup-storage-availability.ts:40: path.join(xdgData(), 'vip')). +func VipDataPath() string { return filepath.Join(paths.XDGData(), "vip") } + +// ConfirmEnoughStorage ports +// validateAndPromptDiskSpaceWarningForBackupImport +// (backup-storage-availability.ts:84): when free space at the vip data +// path exceeds the archive size, continue silently; otherwise prompt. +// freeBytes and confirm are injected for tests; promptShown reports +// whether the user was asked (the command uses it to re-pad the +// progress frame, export-sql.ts:429-438). +func ConfirmEnoughStorage(archiveSize int64, freeBytes func() (int64, error), confirm func(message string) (bool, error)) (cont bool, promptShown bool, err error) { + free, err := freeBytes() + if err != nil { + return false, false, err + } + if free > archiveSize { + return true, false, nil + } + msg := fmt.Sprintf("We recommend that you have at least %s of free space in your machine to download this database backup. Do you still want to continue with downloading the database backup?", + FormatMetricBytes(archiveSize)) + ok, err := confirm(msg) + if err != nil { + return false, true, err + } + return ok, true, nil +} diff --git a/internal/sqlexport/diskspace_unix.go b/internal/sqlexport/diskspace_unix.go new file mode 100644 index 000000000..36087e2cf --- /dev/null +++ b/internal/sqlexport/diskspace_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +package sqlexport + +import ( + "os" + + "golang.org/x/sys/unix" +) + +// FreeBytesAt reports the free disk space available to the current user +// at path (the check-disk-space equivalent). The path is created if +// missing so Statfs has something to stat (the vip data dir may not +// exist on first run). +func FreeBytesAt(path string) (int64, error) { + if err := os.MkdirAll(path, 0o755); err != nil { + return 0, err + } + var st unix.Statfs_t + if err := unix.Statfs(path, &st); err != nil { + return 0, err + } + return int64(st.Bavail) * int64(st.Bsize), nil // #nosec G115 -- disk sizes fit int64 +} diff --git a/internal/sqlexport/diskspace_windows.go b/internal/sqlexport/diskspace_windows.go new file mode 100644 index 000000000..125beb242 --- /dev/null +++ b/internal/sqlexport/diskspace_windows.go @@ -0,0 +1,26 @@ +//go:build windows + +package sqlexport + +import ( + "os" + + "golang.org/x/sys/windows" +) + +// FreeBytesAt reports the free disk space available to the current user +// at path. +func FreeBytesAt(path string) (int64, error) { + if err := os.MkdirAll(path, 0o755); err != nil { + return 0, err + } + var freeBytesAvailable, totalBytes, totalFreeBytes uint64 + p, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + if err := windows.GetDiskFreeSpaceEx(p, &freeBytesAvailable, &totalBytes, &totalFreeBytes); err != nil { + return 0, err + } + return int64(freeBytesAvailable), nil // #nosec G115 -- disk sizes fit int64 +} diff --git a/internal/sqlexport/download.go b/internal/sqlexport/download.go new file mode 100644 index 000000000..eecbe2cc8 --- /dev/null +++ b/internal/sqlexport/download.go @@ -0,0 +1,75 @@ +package sqlexport + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + + "github.com/Automattic/vip/internal/httpproxy" +) + +// OnProgress receives (bytesDownloaded, totalBytes); totalBytes is -1 +// when the response has no Content-Length (download-file.ts:32). +type OnProgress func(downloaded, total int64) + +// DownloadFile ports lib/http/download-file.ts: stream url to +// destinationPath, reporting progress per chunk. On write failure the +// partial file is removed. +func DownloadFile(ctx context.Context, url, destinationPath string, onProgress OnProgress) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("Request to %s failed: %s", url, err.Error()) + } + // NOT http.DefaultClient: the export URL is presigned, so its query string + // is the credential. See internal/httpproxy. (Node's download-file.ts uses + // the global fetch, which proxies nothing at all; the divergence is that a + // user who set VIP_PROXY now gets the download proxied too — an opt-in they + // asked for, and the only alternative to leaking a signed URL to an + // unapproved proxy.) + resp, err := httpproxy.Client().Do(req) + if err != nil { + return fmt.Errorf("Request to %s failed: %s", url, err.Error()) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + // download-file.ts:24 — "Status: ". + return fmt.Errorf("Failed to download file. Status: %d %s", + resp.StatusCode, http.StatusText(resp.StatusCode)) + } + + total := resp.ContentLength // -1 when missing, matching Node's null + + out, err := os.Create(destinationPath) // #nosec G304 -- user-chosen output path + if err != nil { + return fmt.Errorf("Failed to write file to disk: %s", err.Error()) + } + + var downloaded int64 + buf := make([]byte, 64*1024) + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + if _, werr := out.Write(buf[:n]); werr != nil { + out.Close() + _ = os.Remove(destinationPath) // download-file.ts:51 partial-file cleanup + return fmt.Errorf("Failed to write file to disk: %s", werr.Error()) + } + downloaded += int64(n) + if onProgress != nil { + onProgress(downloaded, total) + } + } + if rerr == io.EOF { + break + } + if rerr != nil { + out.Close() + _ = os.Remove(destinationPath) + return fmt.Errorf("Failed to write file to disk: %s", rerr.Error()) + } + } + return out.Close() +} diff --git a/internal/sqlexport/download_test.go b/internal/sqlexport/download_test.go new file mode 100644 index 000000000..ac3a721fa --- /dev/null +++ b/internal/sqlexport/download_test.go @@ -0,0 +1,103 @@ +package sqlexport + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestFormatBytes(t *testing.T) { + for in, want := range map[int64]string{ + 0: "0 bytes", + 512: "512 bytes", + 1024: "1 KB", + 1536: "1.5 KB", + 1048576: "1 MB", + } { + if got := FormatBytes(in); got != want { + t.Errorf("FormatBytes(%d) = %q, want %q", in, got, want) + } + } + if got := FormatMetricBytes(1000); got != "1 KB" { + t.Errorf("FormatMetricBytes(1000) = %q", got) + } + if got := FormatMetricBytes(1500000000); got != "1.5 GB" { + t.Errorf("FormatMetricBytes(1.5GB) = %q", got) + } +} + +func TestDownloadFileHappyPath(t *testing.T) { + body := strings.Repeat("x", 200000) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // Explicit Content-Length so the progress callback sees a total + // (large bodies otherwise go chunked in httptest). + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "out.sql.gz") + var lastDownloaded, lastTotal int64 + err := DownloadFile(context.Background(), srv.URL, dest, func(d, total int64) { + lastDownloaded, lastTotal = d, total + }) + if err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(dest) // #nosec G304 + if len(got) != len(body) { + t.Errorf("len = %d", len(got)) + } + if lastDownloaded != int64(len(body)) || lastTotal != int64(len(body)) { + t.Errorf("progress = %d/%d", lastDownloaded, lastTotal) + } +} + +func TestDownloadFileNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "gone", http.StatusNotFound) + })) + defer srv.Close() + err := DownloadFile(context.Background(), srv.URL, filepath.Join(t.TempDir(), "x"), nil) + // download-file.ts:24. + if err == nil || !strings.Contains(err.Error(), "Failed to download file. Status: 404 Not Found") { + t.Errorf("err = %v", err) + } +} + +func TestConfirmEnoughStorage(t *testing.T) { + t.Setenv("NO_COLOR", "1") + // plenty of space → no prompt + cont, shown, err := ConfirmEnoughStorage(10, + func() (int64, error) { return 1000, nil }, + func(string) (bool, error) { t.Fatal("must not prompt"); return false, nil }) + if err != nil || !cont || shown { + t.Errorf("cont=%v shown=%v err=%v", cont, shown, err) + } + // tight space → prompt with the recommendation message + var msg string + cont, shown, err = ConfirmEnoughStorage(2_000_000_000, + func() (int64, error) { return 10, nil }, + func(m string) (bool, error) { msg = m; return true, nil }) + if err != nil || !cont || !shown { + t.Errorf("cont=%v shown=%v err=%v", cont, shown, err) + } + if !strings.Contains(msg, "We recommend that you have at least 2 GB of free space in your machine to download this database backup.") { + t.Errorf("msg = %q", msg) + } +} + +func TestFreeBytesAt(t *testing.T) { + free, err := FreeBytesAt(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if free <= 0 { + t.Errorf("free = %d", free) + } +} diff --git a/internal/sqlexport/export.go b/internal/sqlexport/export.go new file mode 100644 index 000000000..a6c2f62d5 --- /dev/null +++ b/internal/sqlexport/export.go @@ -0,0 +1,321 @@ +package sqlexport + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/fatih/color" + + "github.com/Automattic/vip/internal/poll" + "github.com/Automattic/vip/internal/tui" +) + +// DefaultPollInterval — EXPORT_SQL_PROGRESS_POLL_INTERVAL (export-sql.ts:34). +const DefaultPollInterval = time.Second + +// DefaultPollTimeout is the ceiling export-sql.ts:547 and :555 inherit by +// calling pollUntil without a timeout: 6 hours (src/lib/utils.ts:18). +const DefaultPollTimeout = poll.DefaultTimeout + +// Step IDs (export-sql.ts:236). +const ( + StepPrepare = "prepare" + StepCreate = "create" + StepDownloadLink = "downloadLink" + StepConfirmEnoughStorage = "confirmEnoughStorage" + StepDownload = "download" +) + +// Steps returns the caller-step seed list for the export tracker +// (export-sql.ts:269-275). +func Steps() []tui.ProgressStep { + return []tui.ProgressStep{ + {ID: StepPrepare, Name: "Preparing for backup download"}, + {ID: StepCreate, Name: "Creating backup copy"}, + {ID: StepDownloadLink, Name: "Requesting download link"}, + {ID: StepConfirmEnoughStorage, Name: "Checking if there's enough storage"}, + {ID: StepDownload, Name: "Downloading file"}, + } +} + +// Backup flattens latestBackup (export-sql.ts:43-50). +type Backup struct { + ID int64 + SQLDumpTool string + CreatedAt string +} + +// ExportJob flattens the db_backup_copy job the workflow polls. +type ExportJob struct { + BackupID int64 // metadata[name=backupId] + UploadPath string // metadata[name=uploadPath] + BytesWritten string // metadata[name=bytesWritten] + StepStatus map[string]string +} + +// BackupAndJobs is one AppBackupAndJobStatus response. +type BackupAndJobs struct { + LatestBackup *Backup + Jobs []ExportJob + EnvSQLDumpTool string +} + +// Deps injects every side effect for tests. +type Deps struct { + FetchStatus func(ctx context.Context) (*BackupAndJobs, error) + CreateExport func(ctx context.Context, backupID int64) error + GenerateLink func(ctx context.Context, backupID int64) (string, error) + RunBackup func(ctx context.Context) error + StartLive func(ctx context.Context, cfg []byte) (string, error) + PollLiveURL func(ctx context.Context, copyID string) (url string, size int64, err error) + Confirm func(message string) (bool, error) + FreeBytes func() (int64, error) + Download func(ctx context.Context, url, dest string, onProgress OnProgress) error +} + +// Options mirror ExportSQLOptions + the env identifiers the messages need. +type Options struct { + OutputFile string + GenerateBackup bool + SkipDownload bool + LiveCopy *LiveCopyCLIOptions + Interval time.Duration + // Timeout caps each export-job poll. Zero means DefaultPollTimeout. + Timeout time.Duration + AppID int64 + AppName string + EnvUniqueLabel string +} + +// exportJobFor finds the job whose backupId metadata matches the latest +// backup (export-sql.ts:296-299). +func exportJobFor(st *BackupAndJobs) *ExportJob { + if st == nil || st.LatestBackup == nil { + return nil + } + for i := range st.Jobs { + if st.Jobs[i].BackupID == st.LatestBackup.ID { + return &st.Jobs[i] + } + } + return nil +} + +// Run ports ExportSQLCommand.run (export-sql.ts:375). It returns the path of +// the saved file, or "" when nothing was saved (SkipDownload, or an error). +// +// The "File saved to " message is intentionally NOT printed here: the +// caller prints it AFTER stopping its progress renderer. Emitting it here — +// while the renderer is still animating the step list on stderr — writes a line +// to stdout that shifts the terminal cursor, so the renderer's final cursor-up +// undershoots and leaves a duplicated top line (the dev-env sync progress bug). +func Run(ctx context.Context, tracker *tui.ProgressTracker, opts Options, deps Deps, out io.Writer) (string, error) { + interval := opts.Interval + if interval == 0 { + interval = DefaultPollInterval + } + timeout := opts.Timeout + if timeout == 0 { + timeout = DefaultPollTimeout + } + + if opts.OutputFile != "" { + dir := filepath.Dir(opts.OutputFile) + if err := checkWritable(dir); err != nil { + return "", fmt.Errorf("Cannot write to the specified path: %s", err.Error()) + } + } + filename := opts.OutputFile + if filename == "" { + filename = "exported.sql.gz" // export-sql.ts:390 + } + + _ = tracker.StepRunning(StepPrepare) + + var url string + var size int64 + + if opts.LiveCopy != nil && opts.LiveCopy.UseLiveBackupCopy { + cfg, err := BuildConfig(opts.LiveCopy) + if err != nil { + return "", err + } + copyID, err := deps.StartLive(ctx, cfg) + if err != nil { + // export-sql.ts:612 wraps every live-copy failure. + return "", fmt.Errorf("Error creating live backup copy: %s", err.Error()) + } + _ = tracker.StepSuccess(StepPrepare) + _ = tracker.StepRunning(StepCreate) + liveURL, liveSize, err := deps.PollLiveURL(ctx, copyID) + if err != nil { + return "", fmt.Errorf("Error creating live backup copy: %s", err.Error()) + } + _ = tracker.StepSuccess(StepCreate) + _ = tracker.StepSuccess(StepDownloadLink, downloadURLLine(liveURL)) + url = liveURL + size = liveSize + } else { + standardURL, err := runStandardBackupFlow(ctx, tracker, opts, deps, out, interval, timeout) + if err != nil { + return "", err + } + url = standardURL + + st, err := deps.FetchStatus(ctx) + if err != nil { + return "", err + } + job := exportJobFor(st) + if job == nil { + return "", errors.New("Export job not found") + } + if job.BytesWritten == "" { + return "", errors.New("Export job metadata does not contain bytesWritten") + } + _, _ = fmt.Sscanf(job.BytesWritten, "%d", &size) + } + + if opts.SkipDownload { + // export-sql.ts:420-427. + _ = tracker.StepSkipped(StepConfirmEnoughStorage) + _ = tracker.StepSkipped(StepDownload) + return "", nil + } + + // Prompt errors (e.g. non-interactive) decline like Node's enquirer + // reject path; FreeBytes errors propagate as-is. + cont, _, err := ConfirmEnoughStorage(size, deps.FreeBytes, deps.Confirm) + if err != nil && !cont { + cont = false + } + if !cont { + _ = tracker.StepFailed(StepConfirmEnoughStorage) + return "", errors.New("Command canceled by user.") + } + _ = tracker.StepSuccess(StepConfirmEnoughStorage) + + // export-sql.ts:449-474 — download with the progress line. + if err := deps.Download(ctx, url, filename, func(current, total int64) { + if total > 0 { + tracker.SetProgress(fmt.Sprintf("- %.2f%% (%s/%s)", + 100*float64(current)/float64(total), FormatBytes(current), FormatBytes(total))) + } + }); err != nil { + _ = tracker.StepFailed(StepDownload) + return "", fmt.Errorf("Error downloading exported file: %s", err.Error()) + } + _ = tracker.StepSuccess(StepDownload) + return filename, nil +} + +// runStandardBackupFlow ports runBackup (export-sql.ts:481). +func runStandardBackupFlow(ctx context.Context, tracker *tui.ProgressTracker, opts Options, deps Deps, out io.Writer, interval, timeout time.Duration) (string, error) { + if opts.GenerateBackup { + // export-sql.ts:350-355 NOTICE block. + notice := "\n" + color.YellowString("NOTICE: ") + + "If a recent database backup does not exist, a new one will be generated for this environment. " + + "Learn more about this: https://docs.wpvip.com/databases/backups/download-a-full-database-backup/ \n" + fmt.Fprintln(out, notice) + if err := deps.RunBackup(ctx); err != nil { + return "", err + } + } + + st, err := deps.FetchStatus(ctx) + if err != nil { + return "", err + } + if st.LatestBackup == nil { + return "", fmt.Errorf("No backup found for site %s", opts.AppName) + } + latest := st.LatestBackup + + var prepareInfo []string + tool := latest.SQLDumpTool + if tool == "" { + tool = st.EnvSQLDumpTool + } + if tool == "mydumper" { + prepareInfo = append(prepareInfo, color.New(color.FgYellow, color.Bold).Sprint("WARNING:")+ + " This is a large or complex database. The backup file for this database is generated with MyDumper. The file can only be loaded with MyLoader. For more information: https://github.com/mydumper/mydumper") + } + + if exportJobFor(st) != nil { + prepareInfo = append(prepareInfo, + fmt.Sprintf("Attaching to an existing export for the backup with timestamp %s", latest.CreatedAt)) + } else { + prepareInfo = append(prepareInfo, + fmt.Sprintf("Exporting database backup with timestamp %s", latest.CreatedAt)) + if err := deps.CreateExport(ctx, latest.ID); err != nil { + // export-sql.ts:525-543. + if strings.Contains(err.Error(), "Backup Copy already in progress") { + return "", fmt.Errorf("There is an export job already running for this environment: https://dashboard.wpvip.com/apps/%d/%s/database/backups\nCurrently, we allow only one export job at a time, per site. Please try again later.", + opts.AppID, opts.EnvUniqueLabel) + } + return "", fmt.Errorf("Error creating export job: %s", err.Error()) + } + } + + // poll preflight success → PREPARE done (export-sql.ts:547-553). + if err := pollStep(ctx, deps, interval, timeout, "preflight"); err != nil { + return "", err + } + _ = tracker.StepSuccess(StepPrepare, prepareInfo...) + + // poll upload_backup success → CREATE done (export-sql.ts:555-560). + if err := pollStep(ctx, deps, interval, timeout, "upload_backup"); err != nil { + return "", err + } + _ = tracker.StepSuccess(StepCreate) + + url, err := deps.GenerateLink(ctx, latest.ID) + if err != nil { + return "", err + } + _ = tracker.StepSuccess(StepDownloadLink, downloadURLLine(url)) + return url, nil +} + +// pollStep waits until the export job's step with the given id reports +// success (isPrepared/isCreated, export-sql.ts:323-337). Node calls pollUntil +// with no timeout, so this sits under the shared 6h ceiling; on expiry the +// PollingTimeoutError propagates uncaught out of runBackup, surfacing as +// "Polling timed out". +func pollStep(ctx context.Context, deps Deps, interval, timeout time.Duration, stepID string) error { + _, err := poll.Until(ctx, deps.FetchStatus, interval, + func(st *BackupAndJobs) bool { + job := exportJobFor(st) + return job != nil && job.StepStatus[stepID] == "success" + }, timeout) + return err +} + +// downloadURLLine — generateDownloadURLOutputString (export-sql.ts:477). +func downloadURLLine(url string) string { + return color.GreenString("Download URL") + ": " + url +} + +// checkWritable mirrors fs.accessSync(dir, W_OK) (export-sql.ts:378). +func checkWritable(dir string) error { + fi, err := os.Stat(dir) + if err != nil { + return err + } + if !fi.IsDir() { + return fmt.Errorf("not a directory: %s", dir) + } + probe, err := os.CreateTemp(dir, ".vip-write-probe-*") + if err != nil { + return err + } + name := probe.Name() + probe.Close() + return os.Remove(name) +} diff --git a/internal/sqlexport/export_test.go b/internal/sqlexport/export_test.go new file mode 100644 index 000000000..d3a5e3ec1 --- /dev/null +++ b/internal/sqlexport/export_test.go @@ -0,0 +1,313 @@ +package sqlexport + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Automattic/vip/internal/tui" +) + +func exportTracker() *tui.ProgressTracker { return tui.NewProgressTracker(Steps()) } + +// happyDeps builds Deps for a standard (non-live) flow that completes. +func happyDeps(t *testing.T, downloadBody string) (Deps, *[]string) { + t.Helper() + var calls []string + fetchCount := 0 + deps := Deps{ + FetchStatus: func(ctx context.Context) (*BackupAndJobs, error) { + fetchCount++ + job := ExportJob{ + BackupID: 11, + BytesWritten: "2048", + StepStatus: map[string]string{}, + } + // First fetch: no job yet (so CreateExport fires); later + // fetches: steps progress to success. + switch { + case fetchCount == 1: + return &BackupAndJobs{ + LatestBackup: &Backup{ID: 11, CreatedAt: "2026-06-11 10:00:00"}, + }, nil + case fetchCount <= 3: + job.StepStatus["preflight"] = "success" + default: + job.StepStatus["preflight"] = "success" + job.StepStatus["upload_backup"] = "success" + } + return &BackupAndJobs{ + LatestBackup: &Backup{ID: 11, CreatedAt: "2026-06-11 10:00:00"}, + Jobs: []ExportJob{job}, + }, nil + }, + CreateExport: func(ctx context.Context, backupID int64) error { + calls = append(calls, "create") + return nil + }, + GenerateLink: func(ctx context.Context, backupID int64) (string, error) { + calls = append(calls, "link") + return "https://dl.example/backup.sql.gz", nil + }, + RunBackup: func(ctx context.Context) error { calls = append(calls, "backup"); return nil }, + Confirm: func(string) (bool, error) { return true, nil }, + FreeBytes: func() (int64, error) { return 1 << 40, nil }, + Download: func(ctx context.Context, url, dest string, onProgress OnProgress) error { + calls = append(calls, "download:"+url+"->"+dest) + if onProgress != nil { + onProgress(1024, 2048) + onProgress(2048, 2048) + } + return nil + }, + } + _ = downloadBody + return deps, &calls +} + +func TestExportRunHappyPath(t *testing.T) { + t.Setenv("NO_COLOR", "1") + deps, calls := happyDeps(t, "data") + var out bytes.Buffer + tr := exportTracker() + saved, err := Run(context.Background(), tr, Options{ + AppID: 42, AppName: "parityapp", EnvUniqueLabel: "develop", Interval: time.Millisecond, + }, deps, &out) + if err != nil { + t.Fatal(err) + } + joined := strings.Join(*calls, "|") + if !strings.Contains(joined, "create") || !strings.Contains(joined, "link") || + !strings.Contains(joined, "download:https://dl.example/backup.sql.gz->exported.sql.gz") { + t.Errorf("calls = %v", *calls) + } + // Run returns the saved path; the caller (not Run) prints "File saved to", + // after stopping its progress renderer. + if saved != "exported.sql.gz" { + t.Errorf("saved = %q, want exported.sql.gz", saved) + } + if strings.Contains(out.String(), "File saved to") { + t.Errorf("Run must not print 'File saved to'; out = %q", out.String()) + } + if !strings.Contains(tr.Frame(), "Exporting database backup with timestamp 2026-06-11 10:00:00") { + t.Errorf("frame missing prepare info: %q", tr.Frame()) + } +} + +func TestExportRunNoBackup(t *testing.T) { + deps, _ := happyDeps(t, "") + deps.FetchStatus = func(ctx context.Context) (*BackupAndJobs, error) { + return &BackupAndJobs{}, nil + } + _, err := Run(context.Background(), exportTracker(), Options{ + AppName: "parityapp", Interval: time.Millisecond, + }, deps, &bytes.Buffer{}) + if err == nil || err.Error() != "No backup found for site parityapp" { + t.Errorf("err = %v", err) + } +} + +func TestExportRunAlreadyInProgress(t *testing.T) { + deps, _ := happyDeps(t, "") + deps.CreateExport = func(ctx context.Context, backupID int64) error { + return errors.New("GraphQL: Backup Copy already in progress") + } + _, err := Run(context.Background(), exportTracker(), Options{ + AppID: 42, AppName: "parityapp", EnvUniqueLabel: "develop", Interval: time.Millisecond, + }, deps, &bytes.Buffer{}) + want := "There is an export job already running for this environment: https://dashboard.wpvip.com/apps/42/develop/database/backups" + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("err = %v", err) + } +} + +func TestExportRunSkipDownload(t *testing.T) { + deps, calls := happyDeps(t, "") + var out bytes.Buffer + saved, err := Run(context.Background(), exportTracker(), Options{ + AppID: 42, AppName: "parityapp", SkipDownload: true, Interval: time.Millisecond, + }, deps, &out) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(*calls, "|"), "download:") { + t.Error("skip-download must not download") + } + if saved != "" { + t.Errorf("skip-download must save nothing; saved = %q", saved) + } + if strings.Contains(out.String(), "File saved to") { + t.Errorf("out = %q", out.String()) + } +} + +func TestExportRunStorageDeclineCancels(t *testing.T) { + deps, _ := happyDeps(t, "") + deps.FreeBytes = func() (int64, error) { return 1, nil } // force prompt + deps.Confirm = func(string) (bool, error) { return false, nil } + _, err := Run(context.Background(), exportTracker(), Options{ + AppID: 42, AppName: "parityapp", Interval: time.Millisecond, + }, deps, &bytes.Buffer{}) + if err == nil || err.Error() != "Command canceled by user." { + t.Errorf("err = %v", err) + } +} + +func TestExportRunMissingBytesWritten(t *testing.T) { + deps, _ := happyDeps(t, "") + orig := deps.FetchStatus + deps.FetchStatus = func(ctx context.Context) (*BackupAndJobs, error) { + st, err := orig(ctx) + if err != nil { + return nil, err + } + for i := range st.Jobs { + st.Jobs[i].BytesWritten = "" + } + return st, nil + } + _, err := Run(context.Background(), exportTracker(), Options{ + AppID: 42, AppName: "parityapp", Interval: time.Millisecond, + }, deps, &bytes.Buffer{}) + if err == nil || err.Error() != "Export job metadata does not contain bytesWritten" { + t.Errorf("err = %v", err) + } +} + +func TestExportRunGenerateBackupPrintsNotice(t *testing.T) { + t.Setenv("NO_COLOR", "1") + deps, calls := happyDeps(t, "") + var out bytes.Buffer + _, err := Run(context.Background(), exportTracker(), Options{ + AppID: 42, AppName: "parityapp", GenerateBackup: true, Interval: time.Millisecond, + }, deps, &out) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(*calls, "|"), "backup") { + t.Error("RunBackup must fire with --generate-backup") + } + if !strings.Contains(out.String(), "NOTICE: ") || + !strings.Contains(out.String(), "If a recent database backup does not exist") { + t.Errorf("out = %q", out.String()) + } +} + +// TestDefaultPollTimeoutIsNodesSixHourCeiling pins the ceiling `vip export +// sql` inherits from Node: export-sql.ts:547 and :555 both call pollUntil +// with no explicit timeout, so both get the 6h default (utils.ts:18). +func TestDefaultPollTimeoutIsNodesSixHourCeiling(t *testing.T) { + if DefaultPollTimeout != 6*time.Hour { + t.Errorf("DefaultPollTimeout = %v, want 6h", DefaultPollTimeout) + } +} + +// TestExportRunStopsWhenJobNeverPrepares is the regression test for the +// unbounded pollStep loop: an export job whose preflight step never reaches +// "success" used to spin forever with nothing cancelling the context. +func TestExportRunStopsWhenJobNeverPrepares(t *testing.T) { + t.Setenv("NO_COLOR", "1") + deps, _ := happyDeps(t, "") + fetches := 0 + deps.FetchStatus = func(ctx context.Context) (*BackupAndJobs, error) { + fetches++ + // The job exists (so no CreateExport) but preflight never succeeds. + return &BackupAndJobs{ + LatestBackup: &Backup{ID: 11, CreatedAt: "2026-06-11 10:00:00"}, + Jobs: []ExportJob{{ + BackupID: 11, + StepStatus: map[string]string{"preflight": "running"}, + }}, + }, nil + } + + done := make(chan error, 1) + go func() { + var out bytes.Buffer + _, err := Run(context.Background(), exportTracker(), Options{ + AppID: 42, AppName: "parityapp", + Interval: time.Millisecond, + Timeout: 50 * time.Millisecond, + }, deps, &out) + done <- err + }() + + select { + case err := <-done: + if err == nil || err.Error() != "Polling timed out" { + t.Errorf("err = %v, want %q", err, "Polling timed out") + } + if fetches < 2 { + t.Errorf("fetches = %d, want the loop to have actually polled", fetches) + } + case <-time.After(5 * time.Second): + t.Fatal("Run never returned: the export-job poll loop is unbounded") + } +} + +// TestExportRunStopsWhenJobNeverUploads covers the SECOND pollUntil +// (export-sql.ts:555): preflight succeeds, upload_backup never does. +func TestExportRunStopsWhenJobNeverUploads(t *testing.T) { + t.Setenv("NO_COLOR", "1") + deps, _ := happyDeps(t, "") + deps.FetchStatus = func(ctx context.Context) (*BackupAndJobs, error) { + return &BackupAndJobs{ + LatestBackup: &Backup{ID: 11, CreatedAt: "2026-06-11 10:00:00"}, + Jobs: []ExportJob{{ + BackupID: 11, + StepStatus: map[string]string{"preflight": "success", "upload_backup": "running"}, + }}, + }, nil + } + + done := make(chan error, 1) + go func() { + var out bytes.Buffer + _, err := Run(context.Background(), exportTracker(), Options{ + AppID: 42, AppName: "parityapp", + Interval: time.Millisecond, + Timeout: 50 * time.Millisecond, + }, deps, &out) + done <- err + }() + + select { + case err := <-done: + if err == nil || err.Error() != "Polling timed out" { + t.Errorf("err = %v, want %q", err, "Polling timed out") + } + case <-time.After(5 * time.Second): + t.Fatal("Run never returned: the upload_backup poll loop is unbounded") + } +} + +func TestExportRunLiveCopyPath(t *testing.T) { + t.Setenv("NO_COLOR", "1") + deps, calls := happyDeps(t, "") + deps.StartLive = func(ctx context.Context, cfg []byte) (string, error) { + if !strings.Contains(string(cfg), `"type":"tables"`) || !strings.Contains(string(cfg), "wp_comments") { + t.Errorf("cfg = %s", cfg) + } + return "copy-1", nil + } + deps.PollLiveURL = func(ctx context.Context, copyID string) (string, int64, error) { + if copyID != "copy-1" { + t.Errorf("copyID = %q", copyID) + } + return "https://dl.example/partial.sql.gz", 4096, nil + } + var out bytes.Buffer + _, err := Run(context.Background(), exportTracker(), Options{ + AppID: 42, AppName: "parityapp", Interval: time.Millisecond, + LiveCopy: &LiveCopyCLIOptions{UseLiveBackupCopy: true, Tables: []string{"wp_posts", "wp_comments"}}, + }, deps, &out) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(*calls, "|"), "download:https://dl.example/partial.sql.gz") { + t.Errorf("calls = %v", *calls) + } +} diff --git a/internal/sqlexport/format.go b/internal/sqlexport/format.go new file mode 100644 index 000000000..b73a56f8e --- /dev/null +++ b/internal/sqlexport/format.go @@ -0,0 +1,44 @@ +// Package sqlexport ports src/commands/export-sql.ts — the `vip export +// sql` workflow: latest-backup lookup, export-job creation + polling, +// download-link generation, partial exports (live backup copy), the +// disk-space confirmation, and the streamed download. +package sqlexport + +import ( + "fmt" + "math" +) + +// formatBytesBase ports format.ts formatBytes: powers of `base` +// (1024 for formatBytes, 1000 for formatMetricBytes), 2 decimals, +// sizes [bytes KB MB GB TB], "0 bytes" for zero. +func formatBytesBase(bytes int64, base float64) string { + if bytes == 0 { + return "0 bytes" + } + sizes := []string{"bytes", "KB", "MB", "GB", "TB"} + i := int(math.Floor(math.Log(float64(bytes)) / math.Log(base))) + if i >= len(sizes) { + i = len(sizes) - 1 + } + if i < 0 { + i = 0 + } + value := float64(bytes) / math.Pow(base, float64(i)) + // Node: parseFloat(value.toFixed(decimals)) — trailing zeros dropped. + s := fmt.Sprintf("%.2f", value) + // Trim trailing zeros and a dangling dot, mirroring parseFloat. + for len(s) > 0 && s[len(s)-1] == '0' { + s = s[:len(s)-1] + } + if len(s) > 0 && s[len(s)-1] == '.' { + s = s[:len(s)-1] + } + return s + " " + sizes[i] +} + +// FormatBytes — format.ts formatBytes default (1024-based). +func FormatBytes(bytes int64) string { return formatBytesBase(bytes, 1024) } + +// FormatMetricBytes — format.ts:231 (1000-based, "how it's displayed on Macs"). +func FormatMetricBytes(bytes int64) string { return formatBytesBase(bytes, 1000) } diff --git a/internal/sqlexport/livecopy.go b/internal/sqlexport/livecopy.go new file mode 100644 index 000000000..3bcb91fac --- /dev/null +++ b/internal/sqlexport/livecopy.go @@ -0,0 +1,150 @@ +package sqlexport + +import ( + "errors" + "fmt" + "os" + "strings" + + "encoding/json/jsontext" + json "encoding/json/v2" +) + +// LiveCopyCLIOptions ports LiveBackupCopyCLIOptions (live-backup-copy.ts:13). +type LiveCopyCLIOptions struct { + UseLiveBackupCopy bool + SiteIDs []string + Tables []string + WpcliCommand string + ConfigFile string +} + +// ParseLiveCopyCLIOptions ports parseLiveBackupCopyCLIOptions +// (live-backup-copy.ts:21): exclusivity rules + comma-split with trim. +func ParseLiveCopyCLIOptions(configFile string, tables, siteIDs []string, wpcliCommand string) (*LiveCopyCLIOptions, error) { + if configFile != "" && (len(tables) > 0 || len(siteIDs) > 0 || wpcliCommand != "") { + return nil, errors.New("The --config-file option cannot be used with the --table, --site-id, or --wpcli-command options. Please use only one of these options at a time.") + } + if wpcliCommand != "" && (len(tables) > 0 || len(siteIDs) > 0) { + return nil, errors.New("The --wpcli-command option cannot be used with the --table or --site-id options. Please use only one of these options at a time.") + } + + opts := &LiveCopyCLIOptions{} + split := func(values []string) []string { + var out []string + for _, v := range values { + for _, part := range strings.Split(v, ",") { + out = append(out, strings.TrimSpace(part)) + } + } + return out + } + if len(tables) > 0 { + opts.Tables = split(tables) + opts.UseLiveBackupCopy = true + } + if len(siteIDs) > 0 { + opts.SiteIDs = split(siteIDs) + opts.UseLiveBackupCopy = true + } + if configFile != "" { + opts.ConfigFile = configFile + opts.UseLiveBackupCopy = true + } + if wpcliCommand != "" { + opts.WpcliCommand = wpcliCommand + opts.UseLiveBackupCopy = true + } + return opts, nil +} + +// LiveCopyConfig ports DBLiveCopyConfig (live-backup-copy.ts:120) for the +// FLAG path only — it is the Go spelling of the object literal Node builds in +// getLiveBackupConfigFromCLIOptions (export-sql.ts:639-644): +// +// return { +// type, +// tables, // undefined unless --table was passed +// site_ids: siteIds, +// wpcli_command: this.liveBackupCopyCLIOptions?.wpcliCommand, +// }; +// +// The `omitempty` tags reproduce JSON.stringify dropping `undefined` fields. +// +// It is deliberately NOT used to parse --config-file. Node's +// loadLiveBackupCopyConfig is `JSON.parse( … ) as DBLiveCopyConfig`: a +// compile-time cast, not a runtime schema. Decoding a user's config file into +// this struct silently discarded every key it doesn't declare and every empty +// collection, changing the scope of the export without any signal. See +// BuildConfig. +type LiveCopyConfig struct { + Tool string `json:"tool,omitempty"` + Type string `json:"type"` + // Values are `string | boolean` in Node (live-backup-copy.ts:123), hence + // `any` rather than `string`. + Tables map[string]map[string]any `json:"tables,omitempty"` + SiteIDs []int64 `json:"site_ids,omitempty"` + WpcliCommand string `json:"wpcli_command,omitempty"` +} + +// BuildConfig ports getLiveBackupConfigFromCLIOptions (export-sql.ts:616) + +// loadLiveBackupCopyConfig (export-sql.ts:647). It returns the JSON document +// that becomes LiveBackupCopyConfigInput.config (a `JSON` scalar in the +// schema), so the two paths differ: +// +// - --config-file: the file's bytes are validated as JSON and passed +// through VERBATIM, because that is what Node does. `JSON.parse` + +// an `as` cast keeps every key the user wrote — including ones the CLI +// has never heard of (`exclude_tables`, `limit`, per-table `where`) — +// and startLiveBackupCopy hands the whole object to the server. Anything +// the CLI drops here silently changes which rows the user gets back, +// with exit 0. Parsing uses encoding/json/v2: a config file is +// untrusted user input, which is exactly where v1 is finicky. +// +// - flags: the LiveCopyConfig literal above, marshaled. +func BuildConfig(opts *LiveCopyCLIOptions) ([]byte, error) { + if opts.ConfigFile != "" { + if _, err := os.Stat(opts.ConfigFile); err != nil { + return nil, fmt.Errorf("Configuration file not found: %s", opts.ConfigFile) + } + raw, err := os.ReadFile(opts.ConfigFile) // #nosec G304 -- user-supplied CLI path + if err != nil { + return nil, fmt.Errorf("Error reading configuration file: %s - %s", opts.ConfigFile, err.Error()) + } + // Validate only — the decoded shape is not inspected. `any` accepts + // any JSON document, matching JSON.parse: Node throws only on a + // SyntaxError, never on an unexpected shape. AllowDuplicateNames + // keeps us from being STRICTER than JSON.parse, which takes the last + // of a duplicated member instead of failing. + var probe any + if err := json.Unmarshal(raw, &probe, jsontext.AllowDuplicateNames(true)); err != nil { + return nil, fmt.Errorf("Invalid JSON in configuration file: %s - %s", opts.ConfigFile, err.Error()) + } + // Re-marshal the *validated* value rather than shipping the file's + // raw bytes: that normalises whitespace and rejects anything the + // validator accepted but an embedder would mangle, while preserving + // every key, every empty collection and every value type. + return json.Marshal(probe) + } + + cfg := &LiveCopyConfig{Type: "tables"} // BackupLiveCopyType.TABLES default (export-sql.ts:621) + if len(opts.Tables) > 0 { + cfg.Tables = map[string]map[string]any{} + for _, t := range opts.Tables { + cfg.Tables[t] = map[string]any{} + } + } + if len(opts.SiteIDs) > 0 { + cfg.Type = "site_ids" + for _, id := range opts.SiteIDs { + var n int64 + _, _ = fmt.Sscanf(strings.TrimSpace(id), "%d", &n) + cfg.SiteIDs = append(cfg.SiteIDs, n) + } + } + if opts.WpcliCommand != "" { + cfg.Type = "wpcli_command" + cfg.WpcliCommand = opts.WpcliCommand + } + return json.Marshal(cfg) +} diff --git a/internal/sqlexport/livecopy_test.go b/internal/sqlexport/livecopy_test.go new file mode 100644 index 000000000..5c1717b73 --- /dev/null +++ b/internal/sqlexport/livecopy_test.go @@ -0,0 +1,273 @@ +package sqlexport + +import ( + "os" + "path/filepath" + "strings" + "testing" + + json "encoding/json/v2" +) + +func TestParseLiveCopyCLIOptionsExclusivity(t *testing.T) { + _, err := ParseLiveCopyCLIOptions("cfg.json", []string{"wp_posts"}, nil, "") + if err == nil || !strings.Contains(err.Error(), "The --config-file option cannot be used with the --table, --site-id, or --wpcli-command options.") { + t.Errorf("err = %v", err) + } + _, err = ParseLiveCopyCLIOptions("", []string{"wp_posts"}, nil, "wp post list") + if err == nil || !strings.Contains(err.Error(), "The --wpcli-command option cannot be used with the --table or --site-id options.") { + t.Errorf("err = %v", err) + } +} + +func TestParseLiveCopyCLIOptionsCommaSplit(t *testing.T) { + opts, err := ParseLiveCopyCLIOptions("", []string{"wp_posts, wp_comments", "wp_users"}, []string{"2,3"}, "") + if err != nil { + t.Fatal(err) + } + if !opts.UseLiveBackupCopy { + t.Error("UseLiveBackupCopy must be set") + } + if len(opts.Tables) != 3 || opts.Tables[1] != "wp_comments" { + t.Errorf("tables = %v", opts.Tables) + } + if len(opts.SiteIDs) != 2 || opts.SiteIDs[1] != "3" { + t.Errorf("siteIDs = %v", opts.SiteIDs) + } +} + +func TestParseLiveCopyCLIOptionsEmpty(t *testing.T) { + opts, err := ParseLiveCopyCLIOptions("", nil, nil, "") + if err != nil { + t.Fatal(err) + } + if opts.UseLiveBackupCopy { + t.Error("no options must not enable live copy") + } +} + +// decodePayload reads the JSON document BuildConfig produces — i.e. exactly +// what lands in LiveBackupCopyConfigInput.config on the wire. +func decodePayload(t *testing.T, raw []byte) map[string]any { + t.Helper() + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("payload is not a JSON object: %v (%s)", err, raw) + } + return got +} + +func writeConfig(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "cfg.json") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func TestBuildConfigFromFlags(t *testing.T) { + raw, err := BuildConfig(&LiveCopyCLIOptions{UseLiveBackupCopy: true, SiteIDs: []string{"2", "3"}}) + if err != nil { + t.Fatal(err) + } + got := decodePayload(t, raw) + if got["type"] != "site_ids" { + t.Errorf("type = %v, want site_ids", got["type"]) + } + ids, _ := got["site_ids"].([]any) + if len(ids) != 2 || ids[1] != float64(3) { + t.Errorf("site_ids = %v", got["site_ids"]) + } + // Node's getLiveBackupConfigFromCLIOptions leaves the unused fields + // `undefined`, and JSON.stringify drops them — so the flag path must NOT + // emit empty tables/wpcli_command keys. + if _, ok := got["tables"]; ok { + t.Errorf("flag path emitted a tables key: %v", got) + } + if _, ok := got["wpcli_command"]; ok { + t.Errorf("flag path emitted a wpcli_command key: %v", got) + } + + raw, err = BuildConfig(&LiveCopyCLIOptions{UseLiveBackupCopy: true, WpcliCommand: "wp post list"}) + if err != nil { + t.Fatal(err) + } + got = decodePayload(t, raw) + if got["type"] != "wpcli_command" || got["wpcli_command"] != "wp post list" { + t.Errorf("cfg = %v", got) + } +} + +func TestBuildConfigFromFile(t *testing.T) { + raw, err := BuildConfig(&LiveCopyCLIOptions{ + UseLiveBackupCopy: true, + ConfigFile: writeConfig(t, `{"type":"tables","tables":{"wp_posts":{}}}`), + }) + if err != nil { + t.Fatal(err) + } + got := decodePayload(t, raw) + tables, _ := got["tables"].(map[string]any) + if got["type"] != "tables" || len(tables) != 1 { + t.Errorf("cfg = %v", got) + } + + dir := t.TempDir() + _, err = BuildConfig(&LiveCopyCLIOptions{UseLiveBackupCopy: true, ConfigFile: filepath.Join(dir, "nope.json")}) + if err == nil || !strings.Contains(err.Error(), "Configuration file not found:") { + t.Errorf("err = %v", err) + } + + _, err = BuildConfig(&LiveCopyCLIOptions{UseLiveBackupCopy: true, ConfigFile: writeConfig(t, "{nope")}) + if err == nil || !strings.Contains(err.Error(), "Invalid JSON in configuration file:") { + t.Errorf("err = %v", err) + } +} + +// TestBuildConfigFromFilePreservesUnknownKeys is register 2.18's first +// defect. Node's loadLiveBackupCopyConfig (export-sql.ts:647-662) is a bare +// `JSON.parse( … ) as DBLiveCopyConfig` — a compile-time cast with no runtime +// filtering — and startLiveBackupCopy passes the parsed object straight into +// the GraphQL `config: JSON` scalar. Every key the user wrote reaches the +// server. Go decoded into a typed struct, so keys the struct didn't declare +// (`exclude_tables`, `limit`, per-table `where` clauses) were silently +// dropped: the user got a dump with the WRONG SCOPE and exit 0. +func TestBuildConfigFromFilePreservesUnknownKeys(t *testing.T) { + raw, err := BuildConfig(&LiveCopyCLIOptions{ + UseLiveBackupCopy: true, + ConfigFile: writeConfig(t, `{ + "type": "tables", + "tool": "mysqldump", + "tables": {"wp_posts": {"where": "ID > 100"}}, + "exclude_tables": ["wp_options", "wp_usermeta"], + "limit": 500 + }`), + }) + if err != nil { + t.Fatal(err) + } + got := decodePayload(t, raw) + + excluded, ok := got["exclude_tables"].([]any) + if !ok || len(excluded) != 2 || excluded[0] != "wp_options" { + t.Errorf("exclude_tables was dropped: %v", got) + } + if got["limit"] != float64(500) { + t.Errorf("limit was dropped: %v", got) + } + tables, _ := got["tables"].(map[string]any) + wpPosts, _ := tables["wp_posts"].(map[string]any) + if wpPosts["where"] != "ID > 100" { + t.Errorf("per-table option was dropped: %v", got) + } + if got["tool"] != "mysqldump" { + t.Errorf("tool was dropped: %v", got) + } +} + +// TestBuildConfigFromFilePreservesEmptyCollections is register 2.18's second +// defect: `omitempty` on the typed struct deleted collections the user wrote +// explicitly. `{"site_ids": []}` is a meaningful (if degenerate) scope; Node +// sends it, Go used to send a config with no site_ids at all — which the +// server reads as a different scope entirely. +func TestBuildConfigFromFilePreservesEmptyCollections(t *testing.T) { + raw, err := BuildConfig(&LiveCopyCLIOptions{ + UseLiveBackupCopy: true, + ConfigFile: writeConfig(t, `{"type":"site_ids","site_ids":[],"tables":{},"wpcli_command":""}`), + }) + if err != nil { + t.Fatal(err) + } + got := decodePayload(t, raw) + if _, ok := got["site_ids"]; !ok { + t.Errorf("empty site_ids was dropped by omitempty: %v", got) + } + if _, ok := got["tables"]; !ok { + t.Errorf("empty tables was dropped by omitempty: %v", got) + } + if _, ok := got["wpcli_command"]; !ok { + t.Errorf("empty wpcli_command was dropped by omitempty: %v", got) + } +} + +// TestBuildConfigFromFileAcceptsBooleanTableOptions is register 2.18's third +// defect. Node's own type says a per-table option value may be a boolean: +// +// tables?: Record< string, Record< string, string | boolean > > +// +// (live-backup-copy.ts:123). Go's `map[string]map[string]string` made that a +// hard unmarshal failure, so a config file Node accepts aborted the export. +func TestBuildConfigFromFileAcceptsBooleanTableOptions(t *testing.T) { + raw, err := BuildConfig(&LiveCopyCLIOptions{ + UseLiveBackupCopy: true, + ConfigFile: writeConfig(t, + `{"type":"tables","tables":{"wp_posts":{"where":"ID > 1","structure_only":true,"skip_data":false}}}`), + }) + if err != nil { + t.Fatalf("boolean per-table option rejected (Node allows string | boolean): %v", err) + } + got := decodePayload(t, raw) + tables, _ := got["tables"].(map[string]any) + wpPosts, _ := tables["wp_posts"].(map[string]any) + if wpPosts["structure_only"] != true { + t.Errorf("structure_only = %v, want true", wpPosts["structure_only"]) + } + if wpPosts["skip_data"] != false { + t.Errorf("skip_data = %v, want false", wpPosts["skip_data"]) + } + if wpPosts["where"] != "ID > 1" { + t.Errorf("where = %v, want the string", wpPosts["where"]) + } +} + +// TestBuildConfigFromFileAllowsDuplicateKeys keeps the jsonv2 port from being +// STRICTER than Node. `JSON.parse` accepts a duplicated object member and +// keeps the last one; jsonv2 rejects duplicates by default, which would turn +// a config file Node runs fine into a hard failure. That would be a new +// divergence introduced by the fix, so it is opted out of explicitly. +func TestBuildConfigFromFileAllowsDuplicateKeys(t *testing.T) { + raw, err := BuildConfig(&LiveCopyCLIOptions{ + UseLiveBackupCopy: true, + ConfigFile: writeConfig(t, `{"type":"tables","type":"site_ids","site_ids":[4]}`), + }) + if err != nil { + t.Fatalf("duplicate key rejected; JSON.parse accepts it (last wins): %v", err) + } + got := decodePayload(t, raw) + if got["type"] != "site_ids" { + t.Errorf("type = %v, want site_ids (JSON.parse keeps the LAST duplicate)", got["type"]) + } +} + +// TestSiteIDCommaSplitIsADeliberateKeep pins cutover register item 1.12. +// +// Node's bin declares `--site-id` with `Number.parseInt` as the coercer +// (src/bin/vip-export-sql.js:86-91), so `--site-id=2,3` arrives as the number +// 2 and site 3 is silently dropped — even though Node's own `--site-id=2,3` +// usage example (vip-export-sql.js:44-46) promises both sites. Go splits on +// the comma and exports BOTH, matching the documented behaviour rather than +// the shipped behaviour. +// +// That divergence is a decided KEEP. This test exists so a future agent +// "fixing" it toward Node has to delete an explicit assertion rather than +// quietly regress the scope of a partial export. +func TestSiteIDCommaSplitIsADeliberateKeep(t *testing.T) { + opts, err := ParseLiveCopyCLIOptions("", nil, []string{"2,3"}, "") + if err != nil { + t.Fatal(err) + } + if len(opts.SiteIDs) != 2 || opts.SiteIDs[0] != "2" || opts.SiteIDs[1] != "3" { + t.Fatalf("SiteIDs = %v, want [2 3] (register 1.12 KEEP; Node's parseInt yields just [2])", opts.SiteIDs) + } + + raw, err := BuildConfig(opts) + if err != nil { + t.Fatal(err) + } + got := decodePayload(t, raw) + ids, _ := got["site_ids"].([]any) + if len(ids) != 2 || ids[0] != float64(2) || ids[1] != float64(3) { + t.Errorf("site_ids = %v, want [2 3] on the wire (register 1.12 KEEP)", got["site_ids"]) + } +} diff --git a/internal/sqlvalidation/devenv_checks_test.go b/internal/sqlvalidation/devenv_checks_test.go new file mode 100644 index 000000000..7e55a90d5 --- /dev/null +++ b/internal/sqlvalidation/devenv_checks_test.go @@ -0,0 +1,134 @@ +package sqlvalidation + +import ( + "strings" + "testing" +) + +// devEnvOptions mirrors the option set Node's dev-env import passes +// (src/commands/dev-env-import-sql.ts:96-100): skipChecks is EMPTY (for a +// mysqldump), which overrides DEFAULT_VALIDATION_OPTIONS.skipChecks and turns +// the two DEV_ENV_SPECIFIC_CHECKS back on, plus the expected local domain as +// siteHomeUrlLando's extraCheckParam. +func devEnvOptions(domain string) Options { + return Options{ExtraCheckParams: map[string]string{CheckSiteHomeURLLando: domain}} +} + +func TestDevEnvOptionsRegisterUseStatement(t *testing.T) { + res, err := ValidateWith(strings.NewReader("USE my_database;\n"), devEnvOptions("e.vipdev.site"), nil) + if err != nil { + t.Fatalf("ValidateWith: %v", err) + } + c := findCheck(t, res, CheckUseStatement) + if len(c.Results) != 1 || c.Results[0].Line != 1 { + t.Errorf("useStatement: got %#v, want one result on line 1", c.Results) + } +} + +func TestDevEnvUseStatementIsCaseInsensitiveAndAnchored(t *testing.T) { + res, _ := ValidateWith(strings.NewReader("use other_db;\nSELECT 'USE something';\n"), devEnvOptions("e.vipdev.site"), nil) + c := findCheck(t, res, CheckUseStatement) + if len(c.Results) != 1 || c.Results[0].Line != 1 { + t.Errorf("useStatement: got %#v, want only the anchored line 1 match", c.Results) + } +} + +// Node sql.ts:344-369. A siteurl/home pointing anywhere but the local domain +// is the finding that matters most for dev-env: importing production SQL +// without a search-replace leaves the LOCAL site redirecting to production. +func TestDevEnvSiteHomeURLLandoFlagsForeignDomain(t *testing.T) { + in := strings.NewReader(`INSERT INTO wp_options VALUES (1,'siteurl','https://example.com');` + "\n") + res, _ := ValidateWith(in, devEnvOptions("e.vipdev.site"), nil) + c := findCheck(t, res, CheckSiteHomeURLLando) + if len(c.Results) != 1 { + t.Fatalf("siteHomeUrlLando: got %#v, want 1 result", c.Results) + } + got := c.Results[0] + if got.FalsePositive { + t.Errorf("foreign domain marked falsePositive: %#v", got) + } + if got.Line != 1 { + t.Errorf("line = %d, want 1", got.Line) + } + want := `Use '--search-replace="example.com,e.vipdev.site"' switch to replace the domain` + if got.Recommendation != want { + t.Errorf("recommendation =\n %q\nwant %q", got.Recommendation, want) + } +} + +// Node's matchHandler returns { falsePositive: true } for three shapes; each +// must NOT produce a finding. +func TestDevEnvSiteHomeURLLandoFalsePositives(t *testing.T) { + cases := []struct { + name string + sql string + }{ + // Not an absolute http(s) URL — Node's /^https?:\/\//i test fails. + {"relative value", `INSERT INTO wp_options VALUES (1,'siteurl','/blog');`}, + // Scheme only: empty after stripping -> trim() is falsy. + {"scheme only", `INSERT INTO wp_options VALUES (1,'home','https://');`}, + // Already points at the local environment. + {"matches expected domain", `INSERT INTO wp_options VALUES (1,'home','https://e.vipdev.site');`}, + // Subdomain of the local environment still "includes" it. + {"subdomain of expected", `INSERT INTO wp_options VALUES (1,'home','https://sub.e.vipdev.site/x');`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res, _ := ValidateWith(strings.NewReader(tc.sql+"\n"), devEnvOptions("e.vipdev.site"), nil) + c := findCheck(t, res, CheckSiteHomeURLLando) + for _, r := range c.Results { + if !r.FalsePositive { + t.Errorf("expected falsePositive, got %#v", r) + } + } + }) + } +} + +// Node's matcher for this check has no /i flag, so it is case-SENSITIVE on the +// option name (unlike most other checks). Pinning it stops a future "cleanup" +// from adding (?i) and diverging. +func TestDevEnvSiteHomeURLLandoMatcherIsCaseSensitive(t *testing.T) { + in := strings.NewReader(`INSERT INTO wp_options VALUES (1,'SITEURL','https://example.com');` + "\n") + res, _ := ValidateWith(in, devEnvOptions("e.vipdev.site"), nil) + if c := findCheck(t, res, CheckSiteHomeURLLando); len(c.Results) != 0 { + t.Errorf("uppercase option name matched: %#v", c.Results) + } +} + +// SkipChecks is honoured: Node passes ['dropTable','dropDB'] for a MyDumper +// dump (dev-env-import-sql.ts:98). +func TestValidateWithSkipChecksOmitsChecks(t *testing.T) { + opts := devEnvOptions("e.vipdev.site") + opts.SkipChecks = []string{"dropTable", "dropDB"} + res, _ := ValidateWith(strings.NewReader("DROP DATABASE wordpress;\n"), opts, nil) + for _, c := range res.Checks { + if c.Key == "dropTable" || c.Key == "dropDB" { + t.Errorf("%s must not be registered when skipped", c.Key) + } + } +} + +// REGRESSION GUARD for the platform path: `vip import validate-sql` and +// `vip import sql` run with DEFAULT_VALIDATION_OPTIONS, whose skipChecks is +// DEV_ENV_SPECIFIC_CHECKS. Neither dev-env check may ever appear there. +func TestPlatformValidateSkipsBothDevEnvChecks(t *testing.T) { + sql := "USE my_database;\n" + + `INSERT INTO wp_options VALUES (1,'siteurl','https://example.com');` + "\n" + res, err := Validate(strings.NewReader(sql)) + if err != nil { + t.Fatalf("Validate: %v", err) + } + for _, c := range res.Checks { + if c.Key == CheckUseStatement || c.Key == CheckSiteHomeURLLando { + t.Errorf("%s must stay skipped on the platform validate-sql path", c.Key) + } + } +} + +func TestPlatformOptionsSkipsDevEnvSpecificChecks(t *testing.T) { + got := PlatformOptions().SkipChecks + if len(got) != 2 || got[0] != CheckUseStatement || got[1] != CheckSiteHomeURLLando { + t.Errorf("PlatformOptions().SkipChecks = %v, want %v", got, DevEnvSpecificChecks) + } +} diff --git a/internal/sqlvalidation/filename.go b/internal/sqlvalidation/filename.go new file mode 100644 index 000000000..2944dba91 --- /dev/null +++ b/internal/sqlvalidation/filename.go @@ -0,0 +1,30 @@ +package sqlvalidation + +import ( + "errors" + "path/filepath" + "regexp" + "strings" +) + +// validFilenameRE — Node sql.ts:106's /^[a-z0-9\-_.]+$/i. +var validFilenameRE = regexp.MustCompile(`(?i)^[a-z0-9\-_.]+$`) + +// ValidateFilename ports validateFilename (sql.ts:105): the import file's +// basename may only contain [0-9 a-z A-Z - _ .]. +func ValidateFilename(filename string) error { + if !validFilenameRE.MatchString(filename) { + return errors.New("Error: The characters used in the name of a file for import are limited to [0-9,a-z,A-Z,-,_,.]") + } + return nil +} + +// ValidateImportFileExtension ports validateImportFileExtension +// (sql.ts:98): only .sql and .gz files can be imported. +func ValidateImportFileExtension(fileName string) error { + ext := strings.ToLower(filepath.Ext(fileName)) + if ext != ".sql" && ext != ".gz" { + return errors.New("Invalid file extension. Please provide a .sql or .gz file.") + } + return nil +} diff --git a/internal/sqlvalidation/line_by_line.go b/internal/sqlvalidation/line_by_line.go new file mode 100644 index 000000000..680cbf49c --- /dev/null +++ b/internal/sqlvalidation/line_by_line.go @@ -0,0 +1,91 @@ +// Package sqlvalidation ports Node's src/lib/validations/sql.ts + +// is-multi-site-sql-dump.ts + line-by-line.ts to Go. Local-only — no +// network calls. +package sqlvalidation + +import ( + "bufio" + "io" +) + +// readBufSize is the size of the rolling read buffer. It is NOT a per-line +// ceiling: lines longer than this are stitched together from successive +// ReadSlice fragments (see ScanLines). +// +// Node's line-by-line.ts uses fd.readLines(), which imposes no per-line +// limit whatsoever. We previously used a bufio.Scanner with a 16MB cap, +// which rejected dumps Node validates fine — `mysqldump --extended-insert` +// packs a whole table into one INSERT, mydumper does the same, and a single +// row with a multi-MB LONGTEXT column is enough on its own. Worse, the +// failure surfaced to the user as the raw Go internal +// "bufio.Scanner: token too long". +// +// Memory: only ONE line is ever held at a time, so a multi-GB dump costs +// max(readBufSize, longest line) — the file is never loaded whole. +const readBufSize = 256 * 1024 + +// ScanLines reads r line-by-line and calls fn for each line payload. Line +// numbers are 1-indexed (Node's lineNum starts at 1 in sql.ts). Returns the +// first non-nil error returned by fn (stops scanning on that line) or any +// underlying read error. +// +// Line splitting matches bufio.ScanLines and Node's readline: '\n' +// terminates a line and a single trailing '\r' is stripped, so CRLF dumps +// behave identically. A final line without a trailing newline is still +// delivered. +// +// Mirrors Node's src/lib/validations/line-by-line.ts getReadInterface + +// the perLineValidations dispatch loop in sql.ts. +func ScanLines(r io.Reader, fn func(line string, lineNum int) error) error { + br := bufio.NewReaderSize(r, readBufSize) + + lineNum := 1 + for { + line, err := readLine(br) + if err != nil && err != io.EOF { + return err + } + // At EOF with nothing buffered there is no final partial line. + if err == io.EOF && len(line) == 0 { + return nil + } + if cbErr := fn(string(trimEOL(line)), lineNum); cbErr != nil { + return cbErr + } + lineNum++ + if err == io.EOF { + return nil + } + } +} + +// readLine returns the next '\n'-terminated chunk, growing past the read +// buffer when necessary. The returned slice may alias br's internal buffer +// when the line fit in one read, so callers must copy (ScanLines converts +// to string immediately) before the next read. +func readLine(br *bufio.Reader) ([]byte, error) { + frag, err := br.ReadSlice('\n') + if err != bufio.ErrBufferFull { + return frag, err + } + // Long line: keep pulling fragments until the delimiter (or EOF). + // append copies frag out of br's buffer before it is reused. + buf := append([]byte(nil), frag...) + for err == bufio.ErrBufferFull { + frag, err = br.ReadSlice('\n') + buf = append(buf, frag...) + } + return buf, err +} + +// trimEOL drops the trailing '\n' and an immediately preceding '\r', +// matching bufio.ScanLines and Node's readline /\r?\n/ split. +func trimEOL(b []byte) []byte { + if n := len(b); n > 0 && b[n-1] == '\n' { + b = b[:n-1] + } + if n := len(b); n > 0 && b[n-1] == '\r' { + b = b[:n-1] + } + return b +} diff --git a/internal/sqlvalidation/line_by_line_test.go b/internal/sqlvalidation/line_by_line_test.go new file mode 100644 index 000000000..dff15cc6f --- /dev/null +++ b/internal/sqlvalidation/line_by_line_test.go @@ -0,0 +1,165 @@ +package sqlvalidation + +import ( + "bytes" + "errors" + "strconv" + "strings" + "testing" +) + +func TestScanLinesBasic(t *testing.T) { + in := strings.NewReader("one\ntwo\nthree\n") + var got []string + var nums []int + err := ScanLines(in, func(line string, n int) error { + got = append(got, line) + nums = append(nums, n) + return nil + }) + if err != nil { + t.Fatalf("ScanLines err: %v", err) + } + wantLines := []string{"one", "two", "three"} + wantNums := []int{1, 2, 3} + if len(got) != len(wantLines) { + t.Fatalf("lines len = %d, want %d", len(got), len(wantLines)) + } + for i := range wantLines { + if got[i] != wantLines[i] { + t.Errorf("line[%d] = %q, want %q", i, got[i], wantLines[i]) + } + if nums[i] != wantNums[i] { + t.Errorf("num[%d] = %d, want %d", i, nums[i], wantNums[i]) + } + } +} + +func TestScanLinesPredicateError(t *testing.T) { + in := strings.NewReader("one\ntwo\nthree\n") + sentinel := errors.New("stop") + var seen []string + err := ScanLines(in, func(line string, n int) error { + seen = append(seen, line) + if n == 2 { + return sentinel + } + return nil + }) + if !errors.Is(err, sentinel) { + t.Fatalf("err = %v, want sentinel", err) + } + if len(seen) != 2 { + t.Errorf("processed %d lines, want 2 (scan should stop on predicate err)", len(seen)) + } +} + +func TestScanLinesLargeLine(t *testing.T) { + // 1MB single-line payload — well within the 16MB cap but far past + // bufio's default 64KB token limit. + big := bytes.Repeat([]byte("x"), 1<<20) + in := bytes.NewReader(append(big, '\n')) + count := 0 + err := ScanLines(in, func(line string, _ int) error { + count++ + if len(line) != 1<<20 { + t.Errorf("got line len %d, want %d", len(line), 1<<20) + } + return nil + }) + if err != nil { + t.Fatalf("ScanLines err: %v", err) + } + if count != 1 { + t.Errorf("processed %d lines, want 1", count) + } +} + +// Register 2.17. Node's line-by-line.ts uses fd.readLines(), which has no +// per-line ceiling at all — a `mysqldump --extended-insert` file, mydumper +// output, or a row with a multi-MB LONGTEXT column routinely produces a +// single INSERT line well past 16MB. Go must read it too, and must never +// surface a bufio internal ("bufio.Scanner: token too long") to the user. +func TestScanLinesLineLargerThanFormer16MBCap(t *testing.T) { + const size = 20 << 20 // 20MB — decisively past the old 16MB cap + big := bytes.Repeat([]byte("x"), size) + in := bytes.NewReader(append(big, '\n')) + + count := 0 + gotLen := 0 + err := ScanLines(in, func(line string, _ int) error { + count++ + gotLen = len(line) + return nil + }) + if err != nil { + t.Fatalf("ScanLines err = %v, want nil (Node has no per-line cap)", err) + } + if count != 1 { + t.Fatalf("processed %d lines, want 1", count) + } + if gotLen != size { + t.Errorf("line len = %d, want %d (line was truncated)", gotLen, size) + } +} + +// A long line must not swallow the lines that follow it. +func TestScanLinesResumesAfterOversizeLine(t *testing.T) { + var buf bytes.Buffer + buf.WriteString("first\n") + buf.Write(bytes.Repeat([]byte("y"), 18<<20)) + buf.WriteString("\nlast\n") + + var got []string + err := ScanLines(&buf, func(line string, _ int) error { + if len(line) > 64 { + got = append(got, "") + return nil + } + got = append(got, line) + return nil + }) + if err != nil { + t.Fatalf("ScanLines err = %v, want nil", err) + } + want := []string{"first", "", "last"} + if len(got) != len(want) { + t.Fatalf("got %d lines %v, want %d %v", len(got), got, len(want), want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("line[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +// bufio.Scanner's ScanLines strips a trailing \r; Node's readline splits on +// /\r?\n/ and does the same. CRLF dumps (Windows-authored mysqldump output) +// must keep behaving that way after the Scanner is replaced. +func TestScanLinesStripsCarriageReturn(t *testing.T) { + in := strings.NewReader("one\r\ntwo\r\n") + var got []string + if err := ScanLines(in, func(line string, _ int) error { + got = append(got, line) + return nil + }); err != nil { + t.Fatalf("ScanLines err: %v", err) + } + if len(got) != 2 || got[0] != "one" || got[1] != "two" { + t.Errorf("got %#v, want [one two]", got) + } +} + +func TestScanLinesNoTrailingNewline(t *testing.T) { + in := strings.NewReader("one\ntwo") + var got []string + if err := ScanLines(in, func(line string, _ int) error { + got = append(got, line) + return nil + }); err != nil { + t.Fatalf("ScanLines err: %v", err) + } + if len(got) != 2 || got[0] != "one" || got[1] != "two" { + t.Errorf("got %#v, want [one two]", got) + } +} diff --git a/internal/sqlvalidation/multisite.go b/internal/sqlvalidation/multisite.go new file mode 100644 index 000000000..af964a1b0 --- /dev/null +++ b/internal/sqlvalidation/multisite.go @@ -0,0 +1,31 @@ +package sqlvalidation + +import "regexp" + +// Multi-site detection regexes — straight port of Node's +// src/lib/validations/is-multi-site-sql-dump.ts. +var ( + // SQL_CREATE_TABLE_IS_MULTISITE_REGEX from is-multi-site-sql-dump.ts:1 + // /^CREATE TABLE(?: IF NOT EXISTS)? `?(wp_\d+_[a-z0-9_]*|wp_blogs)/i + sqlCreateTableIsMultisiteRE = regexp.MustCompile( + `(?i)^CREATE TABLE(?: IF NOT EXISTS)? ` + "`" + `?(wp_\d+_[a-z0-9_]*|wp_blogs)`, + ) + // SQL_CONTAINS_MULTISITE_WP_USERS_REGEX from is-multi-site-sql-dump.ts:3 + // /`spam` tinyint\(2\)|`deleted` tinyint\(2\)/i + sqlContainsMultisiteWPUsersRE = regexp.MustCompile( + "(?i)`spam` tinyint\\(2\\)|`deleted` tinyint\\(2\\)", + ) +) + +// IsMultiSiteSQLDumpLine returns true if the given SQL line is evidence the +// dump comes from a WordPress multisite install. Mirrors Node's +// sqlDumpLineIsMultiSite (is-multi-site-sql-dump.ts). +// +// Two heuristics, OR'd together: +// - CREATE TABLE [IF NOT EXISTS] wp__ OR wp_blogs +// - lines defining the wp_users multisite columns (`spam` tinyint(2), +// `deleted` tinyint(2)) +func IsMultiSiteSQLDumpLine(line string) bool { + return sqlCreateTableIsMultisiteRE.MatchString(line) || + sqlContainsMultisiteWPUsersRE.MatchString(line) +} diff --git a/internal/sqlvalidation/multisite_test.go b/internal/sqlvalidation/multisite_test.go new file mode 100644 index 000000000..89f2097e1 --- /dev/null +++ b/internal/sqlvalidation/multisite_test.go @@ -0,0 +1,34 @@ +package sqlvalidation + +import "testing" + +// Cases mirror Node's __tests__/lib/validations/is-multi-site-sql-dump.js. +func TestIsMultiSiteSQLDumpLine(t *testing.T) { + type tc struct { + line string + want bool + } + cases := []tc{ + // True: multisite CREATE TABLE lines. + {"CREATE TABLE wp_2_posts", true}, + {"CREATE TABLE wp_23_posts", true}, + {"CREATE TABLE wp_2345235_posts", true}, + {"CREATE TABLE wp_blogs", true}, + {"CREATE TABLE IF NOT EXISTS wp_2_posts", true}, + // False: single-site CREATE TABLE. + {"CREATE TABLE wp_posts", false}, + {"CREATE TABLE IF NOT EXISTS wp_posts", false}, + // True: multisite wp_users columns. + {"`spam` tinyint(2) NOT NULL DEFAULT 0,", true}, + {"`deleted` tinyint(2) NOT NULL DEFAULT 0,", true}, + // Case-insensitive parity. + {"create table wp_5_options", true}, + // Empty. + {"", false}, + } + for _, c := range cases { + if got := IsMultiSiteSQLDumpLine(c.line); got != c.want { + t.Errorf("IsMultiSiteSQLDumpLine(%q) = %v, want %v", c.line, got, c.want) + } + } +} diff --git a/internal/sqlvalidation/sql.go b/internal/sqlvalidation/sql.go new file mode 100644 index 000000000..7505298d6 --- /dev/null +++ b/internal/sqlvalidation/sql.go @@ -0,0 +1,510 @@ +package sqlvalidation + +import ( + "io" + "os" + "regexp" + "strings" +) + +// FormatterKind classifies how a check's accumulated results are rendered +// in the validate-sql summary. Mirrors the four outputFormatter closures +// in Node's src/lib/validations/sql.ts: +// +// - FormatterLineNumber: lineNumberCheckFormatter — joins lineNumbers and +// emits " on line(s) X, Y, Z."; " was found 0 times." +// when empty. +// - FormatterRequired: requiredCheckFormatter — inverts: 0 results is the +// PROBLEM (" was not found."). For createTable, also runs the +// wp_ / wp__ prefix sub-classifier. +// - FormatterInfo: infoCheckFormatter — pushes every result.Text as an +// info line; never produces an error. +// - FormatterGeneral: generalCheckFormatter — drops FalsePositive results, +// then emits one line PER surviving result (" on line N.", +// singular) with that result's own Recommendation when it has one. +// +// FormatterGeneral is used only by siteHomeUrlLando, which validate-sql +// always skips (DEV_ENV_SPECIFIC_CHECKS). Its only consumer is the dev-env +// import renderer in internal/devenv; the platform renderer in +// cmd/vip-next/commands/sqlreport.go never sees it. +type FormatterKind int + +const ( + FormatterLineNumber FormatterKind = iota + FormatterRequired + FormatterInfo + FormatterGeneral +) + +// Check keys that Node lists in DEV_ENV_SPECIFIC_CHECKS (sql.ts:394). They +// are skipped by every platform caller and registered only by the dev-env +// import path. +const ( + CheckUseStatement = "useStatement" + CheckSiteHomeURLLando = "siteHomeUrlLando" +) + +// DevEnvSpecificChecks ports DEV_ENV_SPECIFIC_CHECKS (sql.ts:394). It is the +// skipChecks value of Node's DEFAULT_VALIDATION_OPTIONS (sql.ts:522-526), so +// every platform entry point (`vip import validate-sql`, `vip import sql`) +// omits both checks. Node's dev-env import OVERRIDES skipChecks with `[]`, +// which is what turns them on there. +var DevEnvSpecificChecks = []string{CheckUseStatement, CheckSiteHomeURLLando} + +// Options mirrors Node's ValidationOptions (sql.ts:68-75) minus isImport, +// which is a rendering concern the callers own. +type Options struct { + // SkipChecks lists check keys to leave unregistered. + SkipChecks []string + // ExtraCheckParams supplies the third argument Node threads into + // matchHandler (sql.ts:544), keyed by check name. Only siteHomeUrlLando + // reads one: the expected local domain. + ExtraCheckParams map[string]string +} + +// PlatformOptions is the port of DEFAULT_VALIDATION_OPTIONS (sql.ts:522): +// skip the two dev-env-specific checks, no extra params. Every platform +// caller must use this (Validate/ValidateFile already do). +func PlatformOptions() Options { + return Options{SkipChecks: DevEnvSpecificChecks} +} + +// CheckResult holds one match captured from a single SQL line. Fields are +// optional — different formatters consume different fields: +// +// - Line: 1-indexed line number where the match was captured. +// - Text: the captured text (table name for dropTable/createTable, raw +// match for siteHomeUrl). +// - FalsePositive: Node's `falsePositive` — the matchHandler looked at the +// match and decided it is not a finding after all. Only siteHomeUrlLando +// sets it. FalsePositive results are dropped before rendering. +// - Recommendation: Node's `recomendation` (sic) — a per-result override of +// the check's own Recommendation. siteHomeUrlLando uses it to name the +// exact --search-replace flag that fixes THIS line. +// +// Node's fourth per-result field, `warning`, is deliberately NOT ported. +// Severity for the dev-env path is owned by the single tier table in +// internal/devenv/importvalidate.go, so there is exactly one place to look. +type CheckResult struct { + Line int + Text string + FalsePositive bool + Recommendation string +} + +// Check mirrors Node's CheckType. Identity is the key it's filed under in +// the checks map (binaryLogging, trigger, etc.); name is the human-readable +// label used in the rendered output. +type Check struct { + Key string // identity (binaryLogging, trigger, ...) + Matcher *regexp.Regexp // compiled from Node's `matcher` field + Message string // Node's `message` + Recommendation string // Node's `recommendation` + Formatter FormatterKind // outputFormatter family + // MatchHandler decides what to record from a successful match. Mirrors + // Node's matchHandler arrow, including its third argument: the + // per-check extraParam from Options.ExtraCheckParams (sql.ts:544). + // Only siteHomeUrlLando reads it; every other handler ignores it. + MatchHandler func(lineNum int, matches []string, extraParam string) CheckResult + Results []CheckResult // accumulated during Validate() +} + +// Result is the full output of Validate(). Order is deterministic: Checks +// retains insertion order, matching Node's Object.entries(checks) iteration +// order (V8 preserves insertion order for non-numeric string keys). +type Result struct { + Checks []*Check + TableNames []string // captured by checkForTableName; used for duplicate detection + IsMultiSite bool // OR of IsMultiSiteSQLDumpLine across every line + LinesProcessed int +} + +// newChecks constructs the check set for opts. Order mirrors Node's +// `checks` object literal in sql.ts:250, which is also its iteration order +// (V8 preserves insertion order for non-numeric string keys). Keys named in +// opts.SkipChecks are not registered, mirroring Node's filter in +// perLineValidations (sql.ts:538) and postValidation (sql.ts:418). +// +// Every regex is compiled from the Node source verbatim with the same flags +// (Go: `(?i)` for case-insensitive). I/O patterns where Node uses a string +// matcher (passed to String.prototype.match which silently wraps it in a +// dynamic RegExp) are translated to a Go RegExp here. +func newChecks(opts Options) []*Check { + skip := make(map[string]bool, len(opts.SkipChecks)) + for _, key := range opts.SkipChecks { + skip[key] = true + } + out := make([]*Check, 0, len(allChecks())) + for _, c := range allChecks() { + if !skip[c.Key] { + out = append(out, c) + } + } + return out +} + +// allChecks builds every check Node declares, in Node's declaration order. +// Callers filter it via newChecks. +func allChecks() []*Check { + return []*Check{ + // sql.ts:251-259 — binaryLogging + // matcher: /SET @@SESSION.sql_log_bin/i + { + Key: "binaryLogging", + Matcher: regexp.MustCompile(`(?i)SET @@SESSION.sql_log_bin`), + Message: "SET @@SESSION.sql_log_bin statement", + Recommendation: "Remove these lines", + Formatter: FormatterLineNumber, + MatchHandler: handlerLine, + }, + // sql.ts:260-270 — trigger + // /^CREATE (\(?DEFINER=`?(\w*)(`@`)?(\w*\.*%?)*`?\)?)?(| )TRIGGER/i + // Go's regexp (RE2) handles this directly. + { + Key: "trigger", + Matcher: regexp.MustCompile("(?i)^CREATE (\\(?DEFINER=`?(\\w*)(`@`)?(\\w*\\.*%?)*`?\\)?)?(| )TRIGGER"), + Message: "TRIGGER statement", + Recommendation: "Remove these lines", + Formatter: FormatterLineNumber, + MatchHandler: handlerLine, + }, + // sql.ts:271-279 — dropDB + // /^DROP DATABASE/i + { + Key: "dropDB", + Matcher: regexp.MustCompile(`(?i)^DROP DATABASE`), + Message: "DROP DATABASE statement", + Recommendation: "Remove these lines", + Formatter: FormatterLineNumber, + MatchHandler: handlerLine, + }, + // sql.ts:280-288 — useStatement. DEV_ENV_SPECIFIC_CHECKS, so every + // platform caller skips it; the dev-env import registers it because + // a `USE ` in the dump would point the import at a database + // other than the environment's own. + // /^USE /i + { + Key: CheckUseStatement, + Matcher: regexp.MustCompile(`(?i)^USE `), + Message: "USE statement", + Recommendation: "Remove these lines", + Formatter: FormatterLineNumber, + MatchHandler: handlerLine, + }, + // sql.ts:289-297 — alterUser + // /^(ALTER USER|SET PASSWORD)/i + { + Key: "alterUser", + Matcher: regexp.MustCompile(`(?i)^(ALTER USER|SET PASSWORD)`), + Message: "ALTER USER statement", + Recommendation: "Remove these lines", + Formatter: FormatterLineNumber, + MatchHandler: handlerLine, + }, + // sql.ts:298-306 — dropTable + // /^DROP TABLE IF EXISTS `?([a-z0-9_]*)/i + // matchHandler: results[1] -> {text: tableName} + { + Key: "dropTable", + Matcher: regexp.MustCompile("(?i)^DROP TABLE IF EXISTS `?([a-z0-9_]*)"), + Message: "DROP TABLE", + Recommendation: "Check import settings to include DROP TABLE statements", + Formatter: FormatterRequired, + MatchHandler: handlerText1, + }, + // sql.ts:307-315 — createTable + // /^CREATE TABLE (?:IF NOT EXISTS )?`?([a-z0-9_]*)/i + // matchHandler: results[1] -> {text: tableName} + { + Key: "createTable", + Matcher: regexp.MustCompile("(?i)^CREATE TABLE (?:IF NOT EXISTS )?`?([a-z0-9_]*)"), + Message: "CREATE TABLE", + Recommendation: "Check import settings to include CREATE TABLE statements", + Formatter: FormatterRequired, + MatchHandler: handlerText1, + }, + // sql.ts:316-325 — alterTable + // /^ALTER TABLE `?([a-z0-9_]*)/i + { + Key: "alterTable", + Matcher: regexp.MustCompile("(?i)^ALTER TABLE `?([a-z0-9_]*)"), + Message: "ALTER TABLE statement", + Recommendation: "Remove these lines and define table structure in the " + + "CREATE TABLE statement instead", + Formatter: FormatterLineNumber, + MatchHandler: handlerLine, + }, + // sql.ts:326-334 — uniqueChecks + // /^SET UNIQUE_CHECKS\s*=\s*0/i + { + Key: "uniqueChecks", + Matcher: regexp.MustCompile(`(?i)^SET UNIQUE_CHECKS\s*=\s*0`), + Message: "SET UNIQUE_CHECKS = 0", + Recommendation: "Disabling 'UNIQUE_CHECKS' is not allowed. These lines should be removed", + Formatter: FormatterLineNumber, + MatchHandler: handlerLine, + }, + // sql.ts:335-343 — siteHomeUrl + // matcher: `['"](siteurl|home)['"],\\s?['"](.*?)['"]` (string -> + // dynamic RegExp; no /i flag, so case-sensitive in Node) + // matchHandler: {text: results[1] + ' ' + results[2]} + { + Key: "siteHomeUrl", + Matcher: regexp.MustCompile(`['"](siteurl|home)['"],\s?['"](.*?)['"]`), + Message: "Siteurl/home matches", + Recommendation: "", + Formatter: FormatterInfo, + MatchHandler: handlerSiteHomeURL, + }, + // sql.ts:344-369 — siteHomeUrlLando. DEV_ENV_SPECIFIC_CHECKS, so the + // platform never registers it. For dev-env it is the highest-value + // check in the file: it catches a production dump whose siteurl/home + // still points at production, which after import leaves the LOCAL + // site redirecting to the live site. + // + // matcher: `['"](siteurl|home)['"],\\s?['"]([^'"]+)['"]` + // (a STRING matcher -> dynamic RegExp with NO /i flag, so the + // option name is matched case-sensitively — unlike most checks.) + // + // NOTE Node marks every finding here `warning: true`, which makes + // generalCheckFormatter skip `problemsFound += 1` — i.e. Node WARNS + // and imports anyway. vip-next treats it as fatal; that severity + // decision lives in the tier table in internal/devenv/importvalidate.go, + // not here. + { + Key: CheckSiteHomeURLLando, + Matcher: regexp.MustCompile(`['"](siteurl|home)['"],\s?['"]([^'"]+)['"]`), + Message: "Siteurl/home options not pointing to lando domain", + Recommendation: "Use search-replace to change environment's domain", + Formatter: FormatterGeneral, + MatchHandler: handlerSiteHomeURLLando, + }, + // sql.ts:370-380 — engineInnoDB + // /\sENGINE\s?=(?!(\s?InnoDB))/i — has negative lookahead, NOT + // supported by RE2. Express the same intent with two-step matching: + // match ENGINE= then check the following token is NOT 'InnoDB'. + // See engineInnoDBMatcher below for the override. + { + Key: "engineInnoDB", + Matcher: nil, // sentinel: dispatch uses engineInnoDBMatch directly + Message: "ENGINE != InnoDB", + Recommendation: "Ensure your application works with InnoDB and update your SQL " + + "dump to include only 'ENGINE=InnoDB' engine definitions in 'CREATE TABLE' " + + "statements. We suggest you search for all 'ENGINE=X' entries and replace " + + "them with 'ENGINE=InnoDB'!", + Formatter: FormatterLineNumber, + MatchHandler: handlerLine, + }, + // sql.ts:381-391 — autoIncrement + // /\s(NOT NULL AUTO_INCREMENT,)/i + // matchHandler: {text: results[1]} + { + Key: "autoIncrement", + Matcher: regexp.MustCompile(`(?i)\s(NOT NULL AUTO_INCREMENT,)`), + Message: "AUTO_INCREMENT attribute", + Recommendation: "Check import settings to include AUTO_INCREMENT attribute in all " + + "the CREATE TABLE statements", + Formatter: FormatterRequired, + MatchHandler: handlerText1, + }, + } +} + +// handlerLine is the lineNumber matchHandler used by 8 of the checks. +func handlerLine(lineNum int, _ []string, _ string) CheckResult { + return CheckResult{Line: lineNum} +} + +// handlerText1 captures results[1] from the match. Used by dropTable, +// createTable, autoIncrement. +func handlerText1(_ int, matches []string, _ string) CheckResult { + if len(matches) < 2 { + return CheckResult{} + } + return CheckResult{Text: matches[1]} +} + +// handlerSiteHomeURL builds " " from results[1] and results[2]. +// Used by siteHomeUrl. +func handlerSiteHomeURL(_ int, matches []string, _ string) CheckResult { + if len(matches) < 3 { + return CheckResult{} + } + return CheckResult{Text: matches[1] + " " + matches[2]} +} + +// httpSchemePrefix / httpSchemePrefixCI port the TWO different regexes Node +// uses back-to-back on the same value in siteHomeUrlLando's matchHandler +// (sql.ts:348 and :351): the guard test is case-INsensitive, the strip is +// case-SENSITIVE. That asymmetry is Node's, and it is load-bearing for the +// output: `HTTP://EXAMPLE.COM` passes the guard but keeps its scheme through +// the strip, so the recommendation Node prints (and we print) names +// `HTTP://EXAMPLE.COM` rather than the bare host. Ported verbatim rather than +// "fixed" so both CLIs recommend the same --search-replace string. +var ( + httpSchemePrefixCI = regexp.MustCompile(`(?i)^https?://`) + httpSchemePrefix = regexp.MustCompile(`^https?://`) +) + +// handlerSiteHomeURLLando ports sql.ts:346-363. extraParam is the expected +// local domain ("."); an empty one would make every absolute +// URL a finding, so callers must supply it. +func handlerSiteHomeURLLando(lineNum int, matches []string, expectedDomain string) CheckResult { + if len(matches) < 3 { + return CheckResult{FalsePositive: true} + } + found := matches[2] + // Node: if ( ! /^https?:\/\//i.test( foundDomain ) ) return falsePositive + if !httpSchemePrefixCI.MatchString(found) { + return CheckResult{FalsePositive: true} + } + // Node: foundDomain = foundDomain.replace( /^https?:\/\//, '' ) + found = httpSchemePrefix.ReplaceAllString(found, "") + // Node: if ( ! foundDomain.trim() ) return falsePositive + if strings.TrimSpace(found) == "" { + return CheckResult{FalsePositive: true} + } + // Node: if ( foundDomain.includes( expectedDomain ) ) return falsePositive + if strings.Contains(found, expectedDomain) { + return CheckResult{FalsePositive: true} + } + return CheckResult{ + Line: lineNum, + Recommendation: `Use '--search-replace="` + found + "," + expectedDomain + `"' switch to replace the domain`, + } +} + +// engineInnoDBHasMatch checks whether a line should be flagged as +// non-InnoDB. Replicates Node's /\sENGINE\s?=(?!(\s?InnoDB))/i which uses +// a negative lookahead RE2 cannot express. We approximate: find every +// `ENGINE=` occurrence and inspect what follows. +var engineInnoDBPrefix = regexp.MustCompile(`(?i)\sENGINE\s?=`) + +func engineInnoDBMatch(line string) bool { + indexes := engineInnoDBPrefix.FindAllStringIndex(line, -1) + for _, idx := range indexes { + tail := line[idx[1]:] + // Node's negative lookahead: NOT followed by (optional space + "InnoDB") + trimmed := tail + if len(trimmed) > 0 && trimmed[0] == ' ' { + trimmed = trimmed[1:] + } + if !strings.HasPrefix(strings.ToLower(trimmed), "innodb") { + return true + } + } + return false +} + +// checkForTableNamePattern mirrors sql.ts:514 — captures the wp_-prefixed +// table name from a CREATE TABLE line: +// +// /(?<=^CREATE\sTABLE\s)`?(?:(wp_[\d+_]?\w+))`?/ +// +// RE2 lacks lookbehind; we anchor on ^CREATE TABLE and use a capturing +// group instead. The Node regex is case-sensitive (no /i flag). +// +// Bug-for-bug parity note: the `[\d+_]?` character class in Node almost +// certainly was meant to be `(\d+_)?` (a digit-run followed by underscore, +// the multisite-prefix shape). Inside a character class the `+` is a +// literal `+`, not a quantifier. We mirror the Node regex verbatim so the +// output stays byte-identical; do NOT "fix" this character class without +// also updating Node upstream — see vip-cli sql.ts:514. +var checkForTableNamePattern = regexp.MustCompile( + "^CREATE TABLE `?(wp_[\\d+_]?\\w+)`?", +) + +func checkForTableName(line string) (string, bool) { + m := checkForTableNamePattern.FindStringSubmatch(line) + if m == nil || len(m) < 2 { + return "", false + } + return m[1], true +} + +// Validate scans r line-by-line and returns the accumulated check results +// + table-name list + multisite flag. Mirrors Node's validate() body in +// sql.ts:570 (minus the post-validation reporting pass, which the handler +// performs against this Result). +// +// PLATFORM semantics: isImport=false, skipChecks=DEV_ENV_SPECIFIC_CHECKS, +// extraCheckParams={} — i.e. PlatformOptions(). `vip import validate-sql` +// and `vip import sql` must keep using this (or ValidateWithLineHook), which +// is what guarantees they never run useStatement or siteHomeUrlLando. +func Validate(r io.Reader) (*Result, error) { + return ValidateWithLineHook(r, nil) +} + +// ValidateWithLineHook is Validate with an optional per-line callback, +// letting `vip import sql` run its site-type capture (wp_site INSERT +// statements, multisite heuristics) and the "Reading line N" ticker in +// the same streaming pass Node's fileLineValidations performs +// (line-by-line.ts:51 dispatches every registered validation per line). +func ValidateWithLineHook(r io.Reader, hook func(line string, lineNum int)) (*Result, error) { + return ValidateWith(r, PlatformOptions(), hook) +} + +// ValidateWith is the general entry point: it honours opts.SkipChecks and +// opts.ExtraCheckParams. The dev-env import path uses it to register the two +// DEV_ENV_SPECIFIC_CHECKS Node turns on there (dev-env-import-sql.ts:96). +func ValidateWith(r io.Reader, opts Options, hook func(line string, lineNum int)) (*Result, error) { + res := &Result{Checks: newChecks(opts)} + + err := ScanLines(r, func(line string, lineNum int) error { + if hook != nil { + hook(line, lineNum) + } + res.LinesProcessed = lineNum + + // Multi-site detection: OR'd across every line, like Node's separate + // pass over the dump in callers that use sqlDumpLineIsMultiSite. + if !res.IsMultiSite && IsMultiSiteSQLDumpLine(line) { + res.IsMultiSite = true + } + + // Per Node's checkForTableName (sql.ts:513), only the wp_-prefixed + // CREATE TABLE name is captured into tableNames for duplicate + // detection — not every table. + if name, ok := checkForTableName(line); ok { + res.TableNames = append(res.TableNames, name) + } + + for _, check := range res.Checks { + extraParam := opts.ExtraCheckParams[check.Key] + // engineInnoDB uses a custom matcher because Node's pattern uses + // a negative lookahead RE2 can't express directly. + if check.Key == "engineInnoDB" { + if engineInnoDBMatch(line) { + check.Results = append(check.Results, check.MatchHandler(lineNum, nil, extraParam)) + } + continue + } + m := check.Matcher.FindStringSubmatch(line) + if m != nil { + check.Results = append(check.Results, check.MatchHandler(lineNum, m, extraParam)) + } + } + return nil + }) + if err != nil { + return nil, err + } + return res, nil +} + +// ValidateFile opens path and delegates to Validate (platform semantics). +// The caller is responsible for surfacing open errors with the Node-parity +// wording. +func ValidateFile(path string) (*Result, error) { + return ValidateFileWith(path, PlatformOptions()) +} + +// ValidateFileWith opens path and delegates to ValidateWith. +func ValidateFileWith(path string, opts Options) (*Result, error) { + f, err := os.Open(path) // #nosec G304 -- path is a user-supplied CLI arg + if err != nil { + return nil, err + } + defer f.Close() + return ValidateWith(f, opts, nil) +} diff --git a/internal/sqlvalidation/sql_test.go b/internal/sqlvalidation/sql_test.go new file mode 100644 index 000000000..bc17e9b3a --- /dev/null +++ b/internal/sqlvalidation/sql_test.go @@ -0,0 +1,206 @@ +package sqlvalidation + +import ( + "strings" + "testing" +) + +// findCheck returns the named check from the Result. Test helper. +func findCheck(t *testing.T, res *Result, key string) *Check { + t.Helper() + for _, c := range res.Checks { + if c.Key == key { + return c + } + } + t.Fatalf("check %q not found", key) + return nil +} + +func TestValidateBinaryLogging(t *testing.T) { + in := strings.NewReader("SET @@SESSION.sql_log_bin = 1;\n") + res, err := Validate(in) + if err != nil { + t.Fatalf("Validate: %v", err) + } + c := findCheck(t, res, "binaryLogging") + if len(c.Results) != 1 || c.Results[0].Line != 1 { + t.Errorf("binaryLogging: got %#v, want [{Line:1}]", c.Results) + } +} + +func TestValidateTrigger(t *testing.T) { + in := strings.NewReader("CREATE DEFINER=`root`@`localhost` TRIGGER my_trigger BEFORE INSERT\n") + res, _ := Validate(in) + c := findCheck(t, res, "trigger") + if len(c.Results) != 1 || c.Results[0].Line != 1 { + t.Errorf("trigger: got %#v", c.Results) + } +} + +func TestValidateDropDatabase(t *testing.T) { + in := strings.NewReader("DROP DATABASE foo;\n") + res, _ := Validate(in) + c := findCheck(t, res, "dropDB") + if len(c.Results) != 1 || c.Results[0].Line != 1 { + t.Errorf("dropDB: got %#v", c.Results) + } +} + +func TestValidateAlterUser(t *testing.T) { + cases := []string{ + "ALTER USER 'root'@'localhost' IDENTIFIED BY 'x';\n", + "SET PASSWORD FOR 'root'@'localhost' = 'x';\n", + } + for _, sql := range cases { + res, _ := Validate(strings.NewReader(sql)) + c := findCheck(t, res, "alterUser") + if len(c.Results) != 1 { + t.Errorf("alterUser %q: got %d results", sql, len(c.Results)) + } + } +} + +func TestValidateDropTable(t *testing.T) { + in := strings.NewReader("DROP TABLE IF EXISTS `wp_users`;\n") + res, _ := Validate(in) + c := findCheck(t, res, "dropTable") + if len(c.Results) != 1 || c.Results[0].Text != "wp_users" { + t.Errorf("dropTable: got %#v", c.Results) + } +} + +func TestValidateCreateTable(t *testing.T) { + in := strings.NewReader("CREATE TABLE `wp_users` (id int);\n") + res, _ := Validate(in) + c := findCheck(t, res, "createTable") + if len(c.Results) != 1 || c.Results[0].Text != "wp_users" { + t.Errorf("createTable: got %#v", c.Results) + } + if len(res.TableNames) != 1 || res.TableNames[0] != "wp_users" { + t.Errorf("tableNames: got %#v, want [wp_users]", res.TableNames) + } +} + +func TestValidateAlterTable(t *testing.T) { + in := strings.NewReader("ALTER TABLE `wp_users` ADD COLUMN x INT;\n") + res, _ := Validate(in) + c := findCheck(t, res, "alterTable") + if len(c.Results) != 1 || c.Results[0].Line != 1 { + t.Errorf("alterTable: got %#v", c.Results) + } +} + +func TestValidateUniqueChecks(t *testing.T) { + in := strings.NewReader("SET UNIQUE_CHECKS = 0;\n") + res, _ := Validate(in) + c := findCheck(t, res, "uniqueChecks") + if len(c.Results) != 1 { + t.Errorf("uniqueChecks: got %#v", c.Results) + } +} + +func TestValidateSiteHomeUrl(t *testing.T) { + in := strings.NewReader(`INSERT INTO wp_options VALUES (1,'siteurl','http://example.com');` + "\n") + res, _ := Validate(in) + c := findCheck(t, res, "siteHomeUrl") + if len(c.Results) != 1 || c.Results[0].Text != "siteurl http://example.com" { + t.Errorf("siteHomeUrl: got %#v", c.Results) + } +} + +func TestValidateEngineInnoDB(t *testing.T) { + // Non-InnoDB engine should be flagged. + in := strings.NewReader(") ENGINE=MyISAM DEFAULT CHARSET=utf8mb4;\n") + res, _ := Validate(in) + c := findCheck(t, res, "engineInnoDB") + if len(c.Results) != 1 { + t.Errorf("engineInnoDB MyISAM: got %d results, want 1", len(c.Results)) + } + + // InnoDB should NOT be flagged. + in = strings.NewReader(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n") + res, _ = Validate(in) + c = findCheck(t, res, "engineInnoDB") + if len(c.Results) != 0 { + t.Errorf("engineInnoDB InnoDB: got %d results, want 0", len(c.Results)) + } + + // Case-insensitive ENGINE=, and an optional space before InnoDB are OK. + in = strings.NewReader(") engine= InnoDB DEFAULT CHARSET=utf8mb4;\n") + res, _ = Validate(in) + c = findCheck(t, res, "engineInnoDB") + if len(c.Results) != 0 { + t.Errorf("engineInnoDB case-insensitive: got %d results, want 0", len(c.Results)) + } +} + +func TestValidateAutoIncrement(t *testing.T) { + in := strings.NewReader(" `id` bigint(20) NOT NULL AUTO_INCREMENT,\n") + res, _ := Validate(in) + c := findCheck(t, res, "autoIncrement") + if len(c.Results) != 1 || c.Results[0].Text != "NOT NULL AUTO_INCREMENT," { + t.Errorf("autoIncrement: got %#v", c.Results) + } +} + +func TestValidateMultiSiteDetection(t *testing.T) { + in := strings.NewReader("CREATE TABLE wp_2_options (id int);\n") + res, _ := Validate(in) + if !res.IsMultiSite { + t.Errorf("IsMultiSite: got false, want true") + } +} + +func TestValidateCleanDump(t *testing.T) { + clean := strings.Join([]string{ + "-- A clean dump.", + "DROP TABLE IF EXISTS `wp_options`;", + "CREATE TABLE `wp_options` (", + " `option_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,", + " PRIMARY KEY (`option_id`)", + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;", + "INSERT INTO `wp_options` VALUES (1, 'siteurl', 'http://example.com');", + }, "\n") + "\n" + res, err := Validate(strings.NewReader(clean)) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if res.IsMultiSite { + t.Errorf("clean dump flagged multisite") + } + // dropTable and createTable present; everything else absent. + if got := len(findCheck(t, res, "dropTable").Results); got != 1 { + t.Errorf("dropTable: got %d, want 1", got) + } + if got := len(findCheck(t, res, "createTable").Results); got != 1 { + t.Errorf("createTable: got %d, want 1", got) + } + for _, key := range []string{"binaryLogging", "trigger", "dropDB", "alterUser", "alterTable", "uniqueChecks", "engineInnoDB"} { + if got := len(findCheck(t, res, key).Results); got != 0 { + t.Errorf("%s in clean dump: got %d, want 0", key, got) + } + } +} + +func TestValidateUseStatementSkipped(t *testing.T) { + // useStatement is in DEV_ENV_SPECIFIC_CHECKS and never registered for + // validate-sql; a USE statement should not appear in any check's results. + in := strings.NewReader("USE my_database;\n") + res, _ := Validate(in) + for _, c := range res.Checks { + if c.Key == "useStatement" { + t.Fatalf("useStatement should not be registered for validate-sql") + } + if len(c.Results) != 0 { + t.Errorf("%s flagged USE line: %#v", c.Key, c.Results) + } + } +} + +func TestValidateFileMissing(t *testing.T) { + _, err := ValidateFile("/nonexistent/path/that/should/not/exist.sql") + if err == nil { + t.Errorf("ValidateFile(missing): got nil, want error") + } +} From f9934a5e4cb1fd7cf4ea5da20c479ed7dfe22d34 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 20 Aug 2026 18:36:40 -0500 Subject: [PATCH 11/32] feat(go): media import, upload and file validation Ported from vip-cli-golang@72ef2f89 (feature/go-rewrite). --- internal/mediaimport/mediaimport.go | 26 ++ internal/mediaimport/mediaimport_test.go | 46 ++++ internal/mediaimport/status.go | 162 +++++++++++ internal/mediaimport/status_test.go | 159 +++++++++++ internal/mediaimport/tracker.go | 116 ++++++++ internal/mediaimport/tracker_test.go | 62 +++++ internal/upload/filemeta.go | 112 ++++++++ internal/upload/filemeta_test.go | 94 +++++++ internal/upload/multipart.go | 212 +++++++++++++++ internal/upload/multipart_test.go | 210 ++++++++++++++ internal/upload/orchestrate.go | 71 +++++ internal/upload/parts.go | 39 +++ internal/upload/parts_test.go | 39 +++ internal/upload/presign.go | 103 +++++++ internal/upload/presign_test.go | 128 +++++++++ internal/upload/proxy_test.go | 55 ++++ internal/upload/putobject.go | 89 ++++++ internal/upload/putobject_test.go | 79 ++++++ internal/upload/retry.go | 45 +++ internal/upload/upload.go | 22 ++ internal/upload/xmlerror.go | 28 ++ internal/validatefiles/files.go | 105 +++++++ internal/validatefiles/files_test.go | 143 ++++++++++ internal/validatefiles/report.go | 172 ++++++++++++ internal/validatefiles/validatefiles.go | 271 +++++++++++++++++++ internal/validatefiles/validatefiles_test.go | 127 +++++++++ 26 files changed, 2715 insertions(+) create mode 100644 internal/mediaimport/mediaimport.go create mode 100644 internal/mediaimport/mediaimport_test.go create mode 100644 internal/mediaimport/status.go create mode 100644 internal/mediaimport/status_test.go create mode 100644 internal/mediaimport/tracker.go create mode 100644 internal/mediaimport/tracker_test.go create mode 100644 internal/upload/filemeta.go create mode 100644 internal/upload/filemeta_test.go create mode 100644 internal/upload/multipart.go create mode 100644 internal/upload/multipart_test.go create mode 100644 internal/upload/orchestrate.go create mode 100644 internal/upload/parts.go create mode 100644 internal/upload/parts_test.go create mode 100644 internal/upload/presign.go create mode 100644 internal/upload/presign_test.go create mode 100644 internal/upload/proxy_test.go create mode 100644 internal/upload/putobject.go create mode 100644 internal/upload/putobject_test.go create mode 100644 internal/upload/retry.go create mode 100644 internal/upload/upload.go create mode 100644 internal/upload/xmlerror.go create mode 100644 internal/validatefiles/files.go create mode 100644 internal/validatefiles/files_test.go create mode 100644 internal/validatefiles/report.go create mode 100644 internal/validatefiles/validatefiles.go create mode 100644 internal/validatefiles/validatefiles_test.go diff --git a/internal/mediaimport/mediaimport.go b/internal/mediaimport/mediaimport.go new file mode 100644 index 000000000..936969ff7 --- /dev/null +++ b/internal/mediaimport/mediaimport.go @@ -0,0 +1,26 @@ +// Package mediaimport ports src/lib/media-import/** — the media-import +// status poller, its progress tracker, and the small helpers the three +// `vip import media*` commands share. +package mediaimport + +import ( + "os" + "strings" +) + +// IsLocalArchive ports isLocalArchive (media-import/utils.ts:3): +// .tar.gz/.tgz/.zip (case-insensitive) AND an existing regular file. +func IsLocalArchive(filePath string) bool { + lower := strings.ToLower(filePath) + if !strings.HasSuffix(lower, ".tar.gz") && !strings.HasSuffix(lower, ".tgz") && + !strings.HasSuffix(lower, ".zip") { + return false + } + fi, err := os.Stat(filePath) + return err == nil && fi.Mode().IsRegular() +} + +// IsSupportedApp ports isSupportedApp (media-file-import.ts:18): +// app.type must be in SUPPORTED_MEDIA_FILE_IMPORT_SITE_TYPES, i.e. +// exactly "WordPress". +func IsSupportedApp(appType string) bool { return appType == "WordPress" } diff --git a/internal/mediaimport/mediaimport_test.go b/internal/mediaimport/mediaimport_test.go new file mode 100644 index 000000000..df22a603c --- /dev/null +++ b/internal/mediaimport/mediaimport_test.go @@ -0,0 +1,46 @@ +package mediaimport + +import ( + "os" + "path/filepath" + "testing" +) + +func TestIsLocalArchive(t *testing.T) { + dir := t.TempDir() + mk := func(name string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + return p + } + targz := mk("a.tar.gz") + tgz := mk("b.TGZ") // case-insensitive (utils.ts:4 toLowerCase) + zip := mk("c.zip") + sql := mk("d.sql") + + for p, want := range map[string]bool{ + targz: true, tgz: true, zip: true, sql: false, + filepath.Join(dir, "missing.zip"): false, // stat fails -> false + } { + if got := IsLocalArchive(p); got != want { + t.Errorf("IsLocalArchive(%q) = %v, want %v", p, got, want) + } + } + // directory with archive extension -> false (stat.isFile, utils.ts:13) + archiveDir := filepath.Join(dir, "fake.zip") + if err := os.MkdirAll(archiveDir, 0o755); err != nil { + t.Fatal(err) + } + if IsLocalArchive(archiveDir) { + t.Error("directory must not count as a local archive") + } +} + +func TestIsSupportedApp(t *testing.T) { + // SUPPORTED_MEDIA_FILE_IMPORT_SITE_TYPES = ['WordPress'] (media-file-import.ts:16) + if !IsSupportedApp("WordPress") || IsSupportedApp("node") || IsSupportedApp("") { + t.Error("IsSupportedApp must accept exactly 'WordPress'") + } +} diff --git a/internal/mediaimport/status.go b/internal/mediaimport/status.go new file mode 100644 index 000000000..831612b13 --- /dev/null +++ b/internal/mediaimport/status.go @@ -0,0 +1,162 @@ +package mediaimport + +import ( + "context" + "strings" + "time" + + json "encoding/json/v2" + + "encoding/json/jsontext" + + "github.com/fatih/color" +) + +// DefaultPollInterval — IMPORT_MEDIA_PROGRESS_POLL_INTERVAL (status.ts:24). +const DefaultPollInterval = time.Second + +// StatusFetch retrieves the current media-import status; a nil Status +// means the API returned no mediaImportStatus for the env (status.ts:225). +type StatusFetch func(ctx context.Context) (*Status, error) + +// CheckStatusOpts configures CheckStatus. +type CheckStatusOpts struct { + Fetch StatusFetch + Tracker *Tracker + Interval time.Duration + // OnPoll fires after each snapshot is applied, before terminal + // checks — the command renders its Status/App suffix block here. + OnPoll func(overallStatus string) +} + +// MediaImportError ports ImportFailedError (status.ts:106): the terminal +// failure carries the final status payload for buildErrorMessage. +type MediaImportError struct { + ErrorText string + Status string + FailureDetails *FailureDetails +} + +func (e *MediaImportError) Error() string { return e.ErrorText } + +// intervalRamp ports the poll-interval growth (status.ts:258-266): after +// TWO_MINUTES the interval grows by the base amount once per minute. +// (Node's comment says "decrease"; the code adds — port the code.) +type intervalRamp struct { + base time.Duration + current time.Duration + startDate time.Time + ramping bool // Node's `pollIntervalDecreasing` +} + +func newIntervalRamp(base time.Duration, now time.Time) *intervalRamp { + return &intervalRamp{base: base, current: base, startDate: now} +} + +func (r *intervalRamp) next(now time.Time) time.Duration { + r.ramping = r.ramping || r.startDate.Before(now.Add(-2*time.Minute)) + if r.ramping && r.startDate.Before(now.Add(-time.Minute)) { + r.current += r.base + r.startDate = now + } + return r.current +} + +// CheckStatus ports mediaImportCheckStatus's getResults loop +// (status.ts:216-275). The command owns rendering, the error-log +// download flow, and exit codes. +func CheckStatus(ctx context.Context, opts CheckStatusOpts) (*Status, error) { + interval := opts.Interval + if interval == 0 { + interval = DefaultPollInterval + } + ramp := newIntervalRamp(interval, time.Now()) + + for { + st, err := opts.Fetch(ctx) + if err != nil { + // status.ts:232 — reject({error: error.message}) + return nil, &MediaImportError{ErrorText: err.Error()} + } + if st == nil { + // status.ts:227. + return nil, &MediaImportError{ErrorText: "Requested app/environment is not available for this operation. If you think this is not correct, please contact Support."} + } + + status := st.Status + if status == "" { + status = "unknown" // status.ts:237 + } + + opts.Tracker.SetStatus(*st) + + if status == "FAILED" { + // status.ts:241-247. + if opts.OnPoll != nil { + opts.OnPoll("FAILED") + } + return nil, &MediaImportError{ + ErrorText: "Import FAILED", Status: "FAILED", FailureDetails: st.FailureDetails, + } + } + + if opts.OnPoll != nil { + opts.OnPoll(status) + } + + if status == "COMPLETED" || status == "ABORTED" { + // status.ts:253 — both resolve successfully. + return st, nil + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(ramp.next(time.Now())): + } + } +} + +// BuildErrorMessage ports buildErrorMessage (status.ts:110). +func BuildErrorMessage(fe *MediaImportError) string { + if fe.Status == "FAILED" && fe.FailureDetails != nil { + var b strings.Builder + b.WriteString(color.RedString("Import failed at status: ")) + b.WriteString(color.New(color.FgHiRed, color.Bold).Sprint(fe.FailureDetails.PreviousStatus) + "\n") + b.WriteString(color.RedString("Errors:")) + for _, v := range fe.FailureDetails.GlobalErrors { + b.WriteString("\n\t- " + color.New(color.FgHiRed, color.Bold).Sprint(v)) + } + return b.String() + } + message := color.RedString(fe.ErrorText) + message += "\n\nPlease check the status of your Import using `vip import media status @mysite.production`" + message += "\n\nIf this error persists and you are not sure on how to fix, please contact support\n" + return message +} + +// BuildFileErrors ports buildFileErrors (status.ts:134). JSON mode is +// JSON.stringify(data, null, '\t') (format.ts:35) — tab-indented. +func BuildFileErrors(fileErrors []FileError, asJSON bool) string { + if asJSON { + out, err := json.Marshal(fileErrors, jsontext.WithIndent("\t")) + if err != nil { + return "" + } + return string(out) + } + var b strings.Builder + for _, fe := range fileErrors { + name := fe.FileName + if name == "" { + name = "N/A" + } + errs := "unknown error" + if len(fe.Errors) > 0 { + errs = strings.Join(fe.Errors, ", ") + } + b.WriteString("File Name: " + name) + b.WriteString("\n\nErrors:\n\t- " + errs + "\n\n\n\n") + } + return b.String() +} diff --git a/internal/mediaimport/status_test.go b/internal/mediaimport/status_test.go new file mode 100644 index 000000000..80591cdd8 --- /dev/null +++ b/internal/mediaimport/status_test.go @@ -0,0 +1,159 @@ +package mediaimport + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +func scripted(snaps []*Status, errs []error) StatusFetch { + i := 0 + return func(ctx context.Context) (*Status, error) { + idx := i + if i < len(snaps)-1 { + i++ + } + var err error + if idx < len(errs) { + err = errs[idx] + } + return snaps[idx], err + } +} + +func TestCheckStatusCompletes(t *testing.T) { + tr := NewTracker() + var polls []string + res, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scripted([]*Status{ + {Status: "RUNNING", FilesTotal: 10, FilesProcessed: 5, HasFilesProcessed: true}, + {Status: "COMPLETED", FilesTotal: 10, FilesProcessed: 10, HasFilesProcessed: true}, + }, nil), + Tracker: tr, + Interval: time.Millisecond, + OnPoll: func(s string) { polls = append(polls, s) }, + }) + if err != nil { + t.Fatal(err) + } + if res.Status != "COMPLETED" { + t.Errorf("res = %+v", res) + } + if len(polls) < 2 || polls[len(polls)-1] != "COMPLETED" { + t.Errorf("polls = %v", polls) + } +} + +func TestCheckStatusAbortedResolves(t *testing.T) { + tr := NewTracker() + res, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scripted([]*Status{{Status: "ABORTED"}}, nil), + Tracker: tr, + Interval: time.Millisecond, + }) + if err != nil || res.Status != "ABORTED" { + t.Errorf("res=%+v err=%v", res, err) + } +} + +func TestCheckStatusFailedRejects(t *testing.T) { + tr := NewTracker() + _, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scripted([]*Status{{ + Status: "FAILED", + FailureDetails: &FailureDetails{ + PreviousStatus: "RUNNING", + GlobalErrors: []string{"boom"}, + }, + }}, nil), + Tracker: tr, + Interval: time.Millisecond, + }) + var fe *MediaImportError + if !errors.As(err, &fe) || fe.Status != "FAILED" { + t.Fatalf("err = %v (%T)", err, err) + } + msg := BuildErrorMessage(fe) + if !strings.Contains(msg, "Import failed at status:") || !strings.Contains(msg, "RUNNING") || + !strings.Contains(msg, "boom") { + t.Errorf("msg = %q", msg) + } + if !tr.HasFailure() { + t.Error("tracker must record the failure") + } +} + +func TestCheckStatusNilStatusRejects(t *testing.T) { + tr := NewTracker() + _, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scripted([]*Status{nil}, nil), + Tracker: tr, + Interval: time.Millisecond, + }) + want := "Requested app/environment is not available for this operation. If you think this is not correct, please contact Support." + var fe *MediaImportError + if !errors.As(err, &fe) || fe.ErrorText != want { + t.Errorf("err = %v", err) + } +} + +func TestCheckStatusFetchErrorRejects(t *testing.T) { + tr := NewTracker() + _, err := CheckStatus(context.Background(), CheckStatusOpts{ + Fetch: scripted([]*Status{nil}, []error{errors.New("network exploded")}), + Tracker: tr, + Interval: time.Millisecond, + }) + var fe *MediaImportError + if !errors.As(err, &fe) || fe.ErrorText != "network exploded" { + t.Errorf("err = %v", err) + } +} + +func TestBuildErrorMessageGenericFallback(t *testing.T) { + fe := &MediaImportError{ErrorText: "network exploded"} + msg := BuildErrorMessage(fe) + for _, want := range []string{ + "network exploded", + "Please check the status of your Import using `vip import media status @mysite.production`", + "If this error persists and you are not sure on how to fix, please contact support", + } { + if !strings.Contains(msg, want) { + t.Errorf("msg missing %q:\n%s", want, msg) + } + } +} + +func TestBuildFileErrors(t *testing.T) { + fileErrors := []FileError{ + {FileName: "a.jpg", Errors: []string{"too big", "bad name"}}, + {FileName: "", Errors: nil}, + } + txt := BuildFileErrors(fileErrors, false) + if !strings.Contains(txt, "File Name: a.jpg") || !strings.Contains(txt, "too big, bad name") || + !strings.Contains(txt, "File Name: N/A") || !strings.Contains(txt, "unknown error") { + t.Errorf("txt = %q", txt) + } + jsonOut := BuildFileErrors(fileErrors, true) + // format.ts:35 — JSON.stringify(data, null, '\t') + if !strings.Contains(jsonOut, "\t\"fileName\": \"a.jpg\"") { + t.Errorf("json = %q", jsonOut) + } +} + +func TestPollIntervalRamp(t *testing.T) { + // status.ts:258-266: base 1s; after two minutes, +1s every minute. + now := time.Now() + r := newIntervalRamp(time.Second, now) + if got := r.next(now.Add(30 * time.Second)); got != time.Second { + t.Errorf("t+30s = %v, want 1s", got) + } + if got := r.next(now.Add(2*time.Minute + time.Second)); got != 2*time.Second { + t.Errorf("after 2m = %v, want 2s", got) + } + if got := r.next(now.Add(3*time.Minute + 2*time.Second)); got != 3*time.Second { + t.Errorf("after 3m = %v, want 3s", got) + } +} diff --git a/internal/mediaimport/tracker.go b/internal/mediaimport/tracker.go new file mode 100644 index 000000000..3be0faa02 --- /dev/null +++ b/internal/mediaimport/tracker.go @@ -0,0 +1,116 @@ +package mediaimport + +import ( + "fmt" + "strings" + "sync" + + "github.com/fatih/color" + + "github.com/Automattic/vip/internal/tui" +) + +// FailureDetails mirrors AppEnvironmentMediaImportStatusFailureDetails. +type FailureDetails struct { + PreviousStatus string + GlobalErrors []string + FileErrorsURL string +} + +// FileError mirrors AppEnvironmentMediaImportStatusFailureDetailsFileErrors. +// JSON tags drive both the error-log download decode and the exported +// JSON report shape (status.ts:139-144). +type FileError struct { + FileName string `json:"fileName"` + Errors []string `json:"errors"` +} + +// Status mirrors the subset of AppEnvironmentMediaImportStatus the +// tracker and poller consume (progress.ts:9 + status.ts:36-47). +// HasFilesProcessed distinguishes 0 from absent (Node checks +// `typeof filesProcessed === 'number'`, progress.ts:66). +type Status struct { + ImportID int64 + SiteID int64 + Status string + FilesTotal int64 + FilesProcessed int64 + HasFilesProcessed bool + FailureDetails *FailureDetails +} + +// GlyphForMediaStatus ports media-import/status.ts:83 getGlyphForStatus. +// spinner is the current braille frame. +func GlyphForMediaStatus(status, spinner string) string { + switch status { + case "INITIALIZING": + return "○" + case "INITIALIZED", "RUNNING", "COMPLETING", "RAN", "VALIDATING", "VALIDATED": + return color.HiBlueString(spinner) + case "COMPLETED": + return color.GreenString("✓") + case "FAILED": + return color.RedString("✕") + case "ABORTED", "ABORTING": + return color.YellowString("⚠️") + default: + return "" + } +} + +// Tracker ports MediaImportProgressTracker (media-import/progress.ts:14). +// Frame() renders `` where logs is the one-line +// files-processed summary (progress.ts:70). Implements the commands' +// frameSource interface. Safe for concurrent use (render ticker vs. +// poller goroutine). +type Tracker struct { + mu sync.Mutex + status Status + hasFailure bool + spinnerIdx int + prefix string + suffix string +} + +func NewTracker() *Tracker { return &Tracker{} } + +func (t *Tracker) SetPrefix(p string) { t.mu.Lock(); defer t.mu.Unlock(); t.prefix = p } +func (t *Tracker) SetSuffix(s string) { t.mu.Lock(); defer t.mu.Unlock(); t.suffix = s } + +// AppendSuffix mirrors Node's `progressTracker.suffix += ...` calls in +// the error-log download flow (status.ts:286 etc.). +func (t *Tracker) AppendSuffix(s string) { t.mu.Lock(); defer t.mu.Unlock(); t.suffix += s } + +// SetStatus ports setStatus (progress.ts:38). +func (t *Tracker) SetStatus(s Status) { + t.mu.Lock() + defer t.mu.Unlock() + if s.Status == "FAILED" { + t.hasFailure = true + } + t.status = s +} + +func (t *Tracker) HasFailure() bool { t.mu.Lock(); defer t.mu.Unlock(); return t.hasFailure } + +// Frame ports print (progress.ts:58): prefix + optional progress line + +// suffix. The spinner advances per Frame call (RunningSprite parity). +func (t *Tracker) Frame() string { + t.mu.Lock() + defer t.mu.Unlock() + spinner := tui.SpinnerGlyphs[t.spinnerIdx] + t.spinnerIdx = (t.spinnerIdx + 1) % len(tui.SpinnerGlyphs) + + logs := "" + if t.status.HasFilesProcessed && t.status.FilesTotal > 0 { + pct := 100 * t.status.FilesProcessed / t.status.FilesTotal + logs = fmt.Sprintf("Imported Files: %d/%d - %d%% %s", + t.status.FilesProcessed, t.status.FilesTotal, pct, + GlyphForMediaStatus(t.status.Status, spinner)) + } + var b strings.Builder + b.WriteString(t.prefix) + b.WriteString(logs) + b.WriteString(t.suffix) + return b.String() +} diff --git a/internal/mediaimport/tracker_test.go b/internal/mediaimport/tracker_test.go new file mode 100644 index 000000000..e6ad7c380 --- /dev/null +++ b/internal/mediaimport/tracker_test.go @@ -0,0 +1,62 @@ +package mediaimport + +import ( + "strings" + "testing" +) + +func TestTrackerFrameProgressLine(t *testing.T) { + tr := NewTracker() + tr.SetPrefix("HEAD\n") + tr.SetSuffix("\nTAIL") + tr.SetStatus(Status{Status: "RUNNING", FilesTotal: 200, FilesProcessed: 50, HasFilesProcessed: true}) + frame := tr.Frame() + // progress.ts:70: `Imported Files: 50/200 - 25% ` + if !strings.Contains(frame, "Imported Files: 50/200 - 25%") { + t.Errorf("frame = %q", frame) + } + if !strings.HasPrefix(frame, "HEAD\n") || !strings.HasSuffix(frame, "\nTAIL") { + t.Errorf("prefix/suffix not rendered: %q", frame) + } +} + +func TestTrackerFrameNoCountsRendersEmptyLogs(t *testing.T) { + tr := NewTracker() + tr.SetStatus(Status{Status: "INITIALIZING"}) + // progress.ts:66: logs only render when filesProcessed is a number AND + // filesTotal is truthy; otherwise prefix+suffix only. + if frame := tr.Frame(); strings.Contains(frame, "Imported Files") { + t.Errorf("frame = %q", frame) + } +} + +func TestTrackerHasFailure(t *testing.T) { + tr := NewTracker() + tr.SetStatus(Status{Status: "FAILED"}) + if !tr.HasFailure() { + t.Error("FAILED status must set hasFailure (progress.ts:39)") + } +} + +func TestGlyphForMediaStatus(t *testing.T) { + // status.ts:83 vocabulary. + for status, want := range map[string]string{ + "INITIALIZING": "○", + "COMPLETED": "✓", + "FAILED": "✕", + "ABORTED": "⚠️", + "ABORTING": "⚠️", + } { + if got := GlyphForMediaStatus(status, "⠋"); !strings.Contains(got, want) { + t.Errorf("GlyphForMediaStatus(%q) = %q, want contains %q", status, got, want) + } + } + if got := GlyphForMediaStatus("bogus", "⠋"); got != "" { + t.Errorf("unknown status must render empty, got %q", got) + } + for _, spinning := range []string{"INITIALIZED", "RUNNING", "COMPLETING", "RAN", "VALIDATING", "VALIDATED"} { + if got := GlyphForMediaStatus(spinning, "⠋"); !strings.Contains(got, "⠋") { + t.Errorf("GlyphForMediaStatus(%q) = %q, want spinner", spinning, got) + } + } +} diff --git a/internal/upload/filemeta.go b/internal/upload/filemeta.go new file mode 100644 index 000000000..040cb9fde --- /dev/null +++ b/internal/upload/filemeta.go @@ -0,0 +1,112 @@ +package upload + +import ( + "compress/gzip" + "crypto/md5" // #nosec G501 -- S3 integrity checksum, Node parity, not a security boundary + "crypto/sha256" + "encoding/hex" + "fmt" + "hash" + "io" + "os" + "path/filepath" +) + +// FileMeta mirrors Node's FileMeta (client-file-uploader.ts:48). +type FileMeta struct { + BaseName string + FileName string + FileSize int64 + IsCompressed bool +} + +// GetFileMeta ports getFileMeta (client-file-uploader.ts:144). +func GetFileMeta(fileName string) (FileMeta, error) { + fi, err := os.Stat(fileName) + if err != nil { + return FileMeta{}, err + } + mime, err := DetectCompressedMimeType(fileName) + if err != nil { + return FileMeta{}, err + } + return FileMeta{ + BaseName: filepath.Base(fileName), + FileName: fileName, + FileSize: fi.Size(), + IsCompressed: mime == "application/zip" || mime == "application/gzip", + }, nil +} + +// DetectCompressedMimeType ports detectCompressedMimeType +// (client-file-uploader.ts:458): sniff the first 4 bytes for the ZIP / +// GZIP magic numbers. Short files (<4 bytes) are fine — Node compares +// hex prefixes against whatever it managed to read, and so do we. +func DetectCompressedMimeType(fileName string) (string, error) { + f, err := os.Open(fileName) // #nosec G304 -- caller-supplied CLI path + if err != nil { + return "", err + } + defer f.Close() + buf := make([]byte, 4) + n, err := io.ReadFull(f, buf) + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + return "", err + } + header := hex.EncodeToString(buf[:n]) + const zipMagic = "504b0304" + const gzMagic = "1f8b" + if len(header) >= len(zipMagic) && header[:len(zipMagic)] == zipMagic { + return "application/zip", nil + } + if len(header) >= len(gzMagic) && header[:len(gzMagic)] == gzMagic { + return "application/gzip", nil + } + return "", nil +} + +// FileHash ports getFileHash (client-file-uploader.ts:84): streamed +// md5/sha256 of the file contents, hex-encoded. Error wording matches +// Node's "Could not generate file hash: ". +func FileHash(fileName, hashType string) (string, error) { + f, err := os.Open(fileName) // #nosec G304 + if err != nil { + return "", fmt.Errorf("Could not generate file hash: %s", err.Error()) + } + defer f.Close() + var h hash.Hash + switch hashType { + case "sha256": + h = sha256.New() + default: + h = md5.New() // #nosec G401 -- Node parity + } + if _, err := io.Copy(h, f); err != nil { + return "", fmt.Errorf("Could not generate file hash: %s", err.Error()) + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// GzipFile ports gzipFile (client-file-uploader.ts:102). Error wording +// matches Node's "Could not compress file: ". +func GzipFile(src, dst string) error { + in, err := os.Open(src) // #nosec G304 + if err != nil { + return fmt.Errorf("Could not compress file: %s", err.Error()) + } + defer in.Close() + out, err := os.Create(dst) // #nosec G304 + if err != nil { + return fmt.Errorf("Could not compress file: %s", err.Error()) + } + zw := gzip.NewWriter(out) + if _, err := io.Copy(zw, in); err != nil { + out.Close() + return fmt.Errorf("Could not compress file: %s", err.Error()) + } + if err := zw.Close(); err != nil { + out.Close() + return fmt.Errorf("Could not compress file: %s", err.Error()) + } + return out.Close() +} diff --git a/internal/upload/filemeta_test.go b/internal/upload/filemeta_test.go new file mode 100644 index 000000000..c5e1789a1 --- /dev/null +++ b/internal/upload/filemeta_test.go @@ -0,0 +1,94 @@ +package upload + +import ( + "bytes" + "compress/gzip" + "os" + "path/filepath" + "testing" +) + +func writeTemp(t *testing.T, name string, content []byte) string { + t.Helper() + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, content, 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func TestDetectCompressedMimeType(t *testing.T) { + gz := writeTemp(t, "x.bin", []byte{0x1f, 0x8b, 0x08, 0x00, 0x00}) + zip := writeTemp(t, "y.bin", []byte{0x50, 0x4b, 0x03, 0x04, 0x00}) + plain := writeTemp(t, "z.sql", []byte("SELECT 1;\n")) + short := writeTemp(t, "s.bin", []byte{0x1f, 0x8b}) + + for path, want := range map[string]string{ + gz: "application/gzip", zip: "application/zip", plain: "", short: "application/gzip", + } { + got, err := DetectCompressedMimeType(path) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("%s: got %q want %q", path, got, want) + } + } +} + +func TestGetFileMeta(t *testing.T) { + p := writeTemp(t, "dump.sql", []byte("CREATE TABLE wp_posts;\n")) + meta, err := GetFileMeta(p) + if err != nil { + t.Fatal(err) + } + if meta.BaseName != "dump.sql" || meta.IsCompressed || meta.FileSize != 23 { + t.Errorf("meta = %+v", meta) + } +} + +func TestFileHashMD5(t *testing.T) { + p := writeTemp(t, "h.txt", []byte("hello")) + got, err := FileHash(p, "md5") + if err != nil { + t.Fatal(err) + } + if got != "5d41402abc4b2a76b9719d911017c592" { + t.Errorf("md5 = %q", got) + } +} + +func TestFileHashSHA256(t *testing.T) { + p := writeTemp(t, "h.txt", []byte("hello")) + got, err := FileHash(p, "sha256") + if err != nil { + t.Fatal(err) + } + if got != "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" { + t.Errorf("sha256 = %q", got) + } +} + +func TestGzipFileRoundTrip(t *testing.T) { + src := writeTemp(t, "in.sql", bytes.Repeat([]byte("a"), 4096)) + dst := filepath.Join(t.TempDir(), "out.sql.gz") + if err := GzipFile(src, dst); err != nil { + t.Fatal(err) + } + f, err := os.Open(dst) + if err != nil { + t.Fatal(err) + } + defer f.Close() + zr, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + var out bytes.Buffer + if _, err := out.ReadFrom(zr); err != nil { + t.Fatal(err) + } + if out.Len() != 4096 { + t.Errorf("round-trip len = %d", out.Len()) + } +} diff --git a/internal/upload/multipart.go b/internal/upload/multipart.go new file mode 100644 index 000000000..0252bdf81 --- /dev/null +++ b/internal/upload/multipart.go @@ -0,0 +1,212 @@ +package upload + +import ( + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + "sync/atomic" +) + +// initiateResult is S3's CreateMultipartUpload response +// (client-file-uploader.ts:328). +type initiateResult struct { + XMLName xml.Name `xml:"InitiateMultipartUploadResult"` + UploadId string `xml:"UploadId"` +} + +// etagResult is one element of the CompleteMultipartUpload payload +// (client-file-uploader.ts:664). +type etagResult struct { + ETag string + PartNumber int +} + +// uploadUsingMultipart ports uploadUsingMultipart +// (client-file-uploader.ts:338). partSize is parameterized for tests; +// production passes UploadPartSize. +func (c *Client) uploadUsingMultipart(ctx context.Context, appID, envID int64, meta FileMeta, partSize int64, progressCb func(string)) (string, error) { + pre, err := c.GetSignedUploadRequestData(ctx, SignedRequestArgs{ + Action: "CreateMultipartUpload", AppID: appID, EnvID: envID, BaseName: meta.BaseName, + }) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, pre.Options.Method, pre.URL, nil) + if err != nil { + return "", err + } + for k, v := range pre.Options.Headers { + req.Header.Set(k, v) + } + resp, err := c.doWithRetry(req, nil) + if err != nil { + return "", err + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var initErr s3Error + if xml.Unmarshal(body, &initErr) == nil && initErr.Code != "" { + // Node: "Unable to create cloud storage object. Error: ..." (ts:373) + return "", fmt.Errorf("Unable to create cloud storage object. Error: %s", + fmt.Sprintf(`{"Code":%q,"Message":%q}`, initErr.Code, initErr.Message)) + } + var init initiateResult + if err := xml.Unmarshal(body, &init); err != nil || init.UploadId == "" { + // Node: "Unable to get Upload ID from cloud storage. Error: " (ts:382) + return "", fmt.Errorf("Unable to get Upload ID from cloud storage. Error: %s", body) + } + + parts, err := getPartBoundariesWithSize(meta.FileSize, partSize) + if err != nil { + return "", err + } + etags, err := c.uploadParts(ctx, appID, envID, meta, init.UploadId, parts, progressCb) + if err != nil { + return "", err + } + return c.completeMultipartUpload(ctx, appID, envID, meta.BaseName, init.UploadId, etags) +} + +// uploadParts ports uploadParts (client-file-uploader.ts:517): bounded +// concurrency (MaxConcurrentPartUploads), shared total-bytes counter +// feeding the progress callback. +func (c *Client) uploadParts(ctx context.Context, appID, envID int64, meta FileMeta, uploadID string, parts []PartBoundary, progressCb func(string)) ([]etagResult, error) { + sem := make(chan struct{}, MaxConcurrentPartUploads) + results := make([]etagResult, len(parts)) + errs := make([]error, len(parts)) + var totalRead atomic.Int64 + var wg sync.WaitGroup + + for i := range parts { + wg.Add(1) + go func(idx int) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + etag, err := c.uploadPart(ctx, appID, envID, meta, parts[idx], uploadID, &totalRead, progressCb) + if err != nil { + errs[idx] = err + return + } + results[idx] = etagResult{ETag: etag, PartNumber: parts[idx].Index + 1} + }(i) + } + wg.Wait() + for _, err := range errs { + if err != nil { + return nil, err + } + } + return results, nil +} + +// uploadPart ports uploadPart (client-file-uploader.ts:606): per-part +// presigned PUT of the byte range [Start, End]; the quoted ETag response +// header is unquoted (Node JSON.parse's it — ts:646). +func (c *Client) uploadPart(ctx context.Context, appID, envID int64, meta FileMeta, part PartBoundary, uploadID string, totalRead *atomic.Int64, progressCb func(string)) (string, error) { + s3PartNumber := part.Index + 1 // S3 multipart is 1-indexed (ts:615) + pre, err := c.GetSignedUploadRequestData(ctx, SignedRequestArgs{ + Action: "UploadPart", AppID: appID, EnvID: envID, BaseName: meta.BaseName, + PartNumber: s3PartNumber, UploadID: uploadID, + }) + if err != nil { + return "", err + } + + makeBody := func() (io.ReadCloser, error) { + f, err := os.Open(meta.FileName) // #nosec G304 + if err != nil { + return nil, err + } + if _, err := f.Seek(part.Start, io.SeekStart); err != nil { + f.Close() + return nil, err + } + return readCloser{ + Reader: &progressReader{ + r: io.LimitReader(f, part.PartSize), + total: meta.FileSize, + read: totalRead, + cb: progressCb, + }, + closer: f, + }, nil + } + + body, err := makeBody() + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, pre.Options.Method, pre.URL, body) + if err != nil { + return "", err + } + for k, v := range pre.Options.Headers { + req.Header.Set(k, v) + } + req.Header.Set("Content-Length", fmt.Sprintf("%d", part.PartSize)) // ts:631 + req.ContentLength = part.PartSize + + resp, err := c.doWithRetry(req, makeBody) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return strings.Trim(resp.Header.Get("ETag"), `"`), nil + } + respBody, _ := io.ReadAll(resp.Body) + // Node: "Unable to upload file part. Error: ..." (ts:659) + return "", fmt.Errorf("Unable to upload file part. Error: %s", formatS3Error(respBody, resp)) +} + +// completeMultipartUpload ports completeMultipartUpload +// (client-file-uploader.ts:696). Returns the raw XML success body (Node +// returns the parsed doc; only its presence matters to callers). +func (c *Client) completeMultipartUpload(ctx context.Context, appID, envID int64, basename, uploadID string, etags []etagResult) (string, error) { + etagMaps := make([]map[string]any, len(etags)) + for i, e := range etags { + etagMaps[i] = map[string]any{"ETag": e.ETag, "PartNumber": e.PartNumber} + } + pre, err := c.GetSignedUploadRequestData(ctx, SignedRequestArgs{ + Action: "CompleteMultipartUpload", AppID: appID, EnvID: envID, + BaseName: basename, UploadID: uploadID, EtagResults: etagMaps, + }) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, pre.Options.Method, pre.URL, strings.NewReader(pre.Options.Body)) + if err != nil { + return "", err + } + for k, v := range pre.Options.Headers { + req.Header.Set(k, v) + } + makeBody := func() (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader(pre.Options.Body)), nil + } + resp, err := c.doWithRetry(req, makeBody) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + // Node: throw await response.text() — a bare string (ts:719). + return "", fmt.Errorf("%s", body) + } + // S3 can return 200 with an body for CompleteMultipartUpload + // (ts:722 comment block). + var compErr s3Error + if xml.Unmarshal(body, &compErr) == nil && compErr.Code != "" { + return "", fmt.Errorf("Unable to complete the upload. Error: %s", + fmt.Sprintf(`{"Code":%q,"Message":%q}`, compErr.Code, compErr.Message)) + } + return string(body), nil +} diff --git a/internal/upload/multipart_test.go b/internal/upload/multipart_test.go new file mode 100644 index 000000000..2556e21c1 --- /dev/null +++ b/internal/upload/multipart_test.go @@ -0,0 +1,210 @@ +package upload + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + json "encoding/json/v2" +) + +// multipartStub implements presign + CreateMultipartUpload + UploadPart + +// CompleteMultipartUpload endpoints. +type multipartStub struct { + t *testing.T + mu sync.Mutex + parts map[int][]byte + maxInFlight int32 + inFlight int32 + failPart2 int32 // fail part #2 this many times (network-level close) + complete []byte + completeBody []byte +} + +const signedCompleteMultipartBody = `etag-11etag-22etag-33` + +func newMultipartTest(t *testing.T) (*multipartStub, *Client) { + st := &multipartStub{t: t, parts: map[int][]byte{}} + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + mux.HandleFunc("/upload/site-import-presigned-url", func(w http.ResponseWriter, r *http.Request) { + var args SignedRequestArgs + b, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(b, &args); err != nil { + st.t.Errorf("bad presign body: %v", err) + } + switch args.Action { + case "CreateMultipartUpload": + fmt.Fprintf(w, `{"url":"%s/s3create","options":{"method":"POST","headers":{}}}`, srv.URL) + case "UploadPart": + fmt.Fprintf(w, `{"url":"%s/s3part/%d","options":{"method":"PUT","headers":{}}}`, srv.URL, args.PartNumber) + case "CompleteMultipartUpload": + st.mu.Lock() + st.complete = b + st.mu.Unlock() + fmt.Fprintf(w, `{"url":"%s/s3complete","options":{"method":"POST","headers":{"Content-Length":"%d","Content-Type":"application/xml"},"body":%q}}`, srv.URL, len(signedCompleteMultipartBody), signedCompleteMultipartBody) + default: + st.t.Errorf("unexpected action %q", args.Action) + } + }) + mux.HandleFunc("/s3create", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`bkUPLOAD123`)) + }) + mux.HandleFunc("/s3part/", func(w http.ResponseWriter, r *http.Request) { + cur := atomic.AddInt32(&st.inFlight, 1) + defer atomic.AddInt32(&st.inFlight, -1) + for { + max := atomic.LoadInt32(&st.maxInFlight) + if cur <= max || atomic.CompareAndSwapInt32(&st.maxInFlight, max, cur) { + break + } + } + var n int + _, _ = fmt.Sscanf(r.URL.Path, "/s3part/%d", &n) + if n == 2 && atomic.AddInt32(&st.failPart2, -1) >= 0 { + // network-level failure: hijack + close so the client sees a + // transport error (the only thing fetch-retry retries). + hj, ok := w.(http.Hijacker) + if !ok { + st.t.Fatal("hijack unsupported") + } + conn, _, err := hj.Hijack() + if err != nil { + st.t.Fatal(err) + } + conn.Close() + return + } + body, _ := io.ReadAll(r.Body) + st.mu.Lock() + st.parts[n] = body + st.mu.Unlock() + w.Header().Set("ETag", fmt.Sprintf(`"etag-%d"`, n)) + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/s3complete", func(w http.ResponseWriter, r *http.Request) { + st.mu.Lock() + st.completeBody, _ = io.ReadAll(r.Body) + st.mu.Unlock() + _, _ = w.Write([]byte(`lbk"final"`)) + }) + return st, &Client{APIHost: srv.URL, Token: "tok", HTTPClient: srv.Client(), + retryDelay: func(int) time.Duration { return 0 }} +} + +func TestMultipartHappyPath(t *testing.T) { + st, c := newMultipartTest(t) + content := bytes.Repeat([]byte("x"), 40) // partSize 16 → 3 parts: 16,16,8 + p := filepath.Join(t.TempDir(), "big.sql") + if err := os.WriteFile(p, content, 0o600); err != nil { + t.Fatal(err) + } + meta, _ := GetFileMeta(p) + if _, err := c.uploadUsingMultipart(context.Background(), 1, 2, meta, 16, nil); err != nil { + t.Fatal(err) + } + if len(st.parts[1]) != 16 || len(st.parts[2]) != 16 || len(st.parts[3]) != 8 { + t.Errorf("part sizes: %d/%d/%d", len(st.parts[1]), len(st.parts[2]), len(st.parts[3])) + } + comp := string(st.complete) + if !strings.Contains(comp, `"ETag":"etag-1"`) || !strings.Contains(comp, `"PartNumber":3`) { + t.Errorf("complete body = %s", comp) + } + if got := string(st.completeBody); got != signedCompleteMultipartBody { + t.Errorf("S3 completion body = %q, want signed body %q", got, signedCompleteMultipartBody) + } +} + +func TestMultipartPartRetrySucceeds(t *testing.T) { + st, c := newMultipartTest(t) + atomic.StoreInt32(&st.failPart2, 2) // fail part 2 twice, succeed third + content := bytes.Repeat([]byte("y"), 40) + p := filepath.Join(t.TempDir(), "big.sql") + if err := os.WriteFile(p, content, 0o600); err != nil { + t.Fatal(err) + } + meta, _ := GetFileMeta(p) + if _, err := c.uploadUsingMultipart(context.Background(), 1, 2, meta, 16, nil); err != nil { + t.Fatal(err) + } + if len(st.parts[2]) != 16 { + t.Errorf("part 2 not uploaded after retries") + } +} + +func TestMultipartPartRetryExhausts(t *testing.T) { + st, c := newMultipartTest(t) + atomic.StoreInt32(&st.failPart2, 99) // never recovers + content := bytes.Repeat([]byte("y"), 40) + p := filepath.Join(t.TempDir(), "big.sql") + if err := os.WriteFile(p, content, 0o600); err != nil { + t.Fatal(err) + } + meta, _ := GetFileMeta(p) + if _, err := c.uploadUsingMultipart(context.Background(), 1, 2, meta, 16, nil); err == nil { + t.Fatal("want error after retry exhaustion") + } +} + +func TestMultipartConcurrencyCap(t *testing.T) { + st, c := newMultipartTest(t) + content := bytes.Repeat([]byte("z"), 16*12) // 12 parts + p := filepath.Join(t.TempDir(), "big.sql") + if err := os.WriteFile(p, content, 0o600); err != nil { + t.Fatal(err) + } + meta, _ := GetFileMeta(p) + if _, err := c.uploadUsingMultipart(context.Background(), 1, 2, meta, 16, nil); err != nil { + t.Fatal(err) + } + if got := atomic.LoadInt32(&st.maxInFlight); got > MaxConcurrentPartUploads { + t.Errorf("max in-flight = %d, want <= %d", got, MaxConcurrentPartUploads) + } +} + +func TestUploadImportFileGzRename(t *testing.T) { + for in, want := range map[string]string{ + "dump.sql": "dump.sql.gz", + "dump.sql.gz": "dump.sql.gz", + "DUMP.SQL.GZ": "DUMP.SQL.gz", + } { + if got := gzRename(in); got != want { + t.Errorf("gzRename(%q) = %q, want %q", in, got, want) + } + } +} + +func TestUploadImportFileSmallUsesPutObject(t *testing.T) { + var sawPut bool + c := stubPresignServer(t, func(w http.ResponseWriter, r *http.Request) { + sawPut = true + w.WriteHeader(http.StatusOK) + }) + p := writeTemp(t, "dump.sql", []byte("SELECT 1;\n")) + meta, _ := GetFileMeta(p) + res, err := c.UploadImportFile(context.Background(), 1, 2, meta, "md5", nil) + if err != nil { + t.Fatal(err) + } + if !sawPut { + t.Error("small file must take the PutObject path") + } + if res.Meta.BaseName != "dump.sql" || res.Meta.IsCompressed { + t.Errorf("meta = %+v (small file must not be compressed)", res.Meta) + } + if len(res.Checksum) != 32 { + t.Errorf("checksum = %q", res.Checksum) + } +} diff --git a/internal/upload/orchestrate.go b/internal/upload/orchestrate.go new file mode 100644 index 000000000..240f4debe --- /dev/null +++ b/internal/upload/orchestrate.go @@ -0,0 +1,71 @@ +package upload + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +// UploadResult mirrors uploadImportFileToS3's return +// (client-file-uploader.ts:222). +type UploadResult struct { + Meta FileMeta + Checksum string // hex md5 (or sha256) of the file as uploaded + Result string +} + +// gzRename mirrors Node's basename.replace(/(.gz)?$/i, '.gz') (ts:193): +// idempotently ensure a single .gz suffix, replacing an existing +// (case-insensitive) one. +func gzRename(base string) string { + if l := strings.ToLower(base); strings.HasSuffix(l, ".gz") { + base = base[:len(base)-3] + } + return base + ".gz" +} + +// UploadImportFile ports uploadImportFileToS3 (client-file-uploader.ts:163): +// 1. gzip-compress when not already compressed and >= CompressThreshold, +// 2. checksum the (possibly compressed) file, +// 3. PutObject below MultipartThreshold, multipart at/above it. +func (c *Client) UploadImportFile(ctx context.Context, appID, envID int64, meta FileMeta, hashType string, progressCb func(string)) (*UploadResult, error) { + if !meta.IsCompressed && meta.FileSize >= CompressThreshold { + tmpDir, err := os.MkdirTemp("", "vip-client-file-uploader") + if err != nil { + return nil, fmt.Errorf("Unable to create temporary working directory: %s", err.Error()) + } + meta.BaseName = gzRename(meta.BaseName) + compressed := filepath.Join(tmpDir, meta.BaseName) + if err := GzipFile(meta.FileName, compressed); err != nil { + return nil, err + } + meta.FileName = compressed + meta.IsCompressed = true + fi, err := os.Stat(compressed) + if err != nil { + return nil, err + } + meta.FileSize = fi.Size() + } + + if hashType == "" { + hashType = "md5" + } + checksum, err := FileHash(meta.FileName, hashType) + if err != nil { + return nil, err + } + + var result string + if meta.FileSize < MultipartThreshold { + result, err = c.uploadUsingPutObject(ctx, appID, envID, meta, progressCb) + } else { + result, err = c.uploadUsingMultipart(ctx, appID, envID, meta, UploadPartSize, progressCb) + } + if err != nil { + return nil, err + } + return &UploadResult{Meta: meta, Checksum: checksum, Result: result}, nil +} diff --git a/internal/upload/parts.go b/internal/upload/parts.go new file mode 100644 index 000000000..e7da5a11b --- /dev/null +++ b/internal/upload/parts.go @@ -0,0 +1,39 @@ +package upload + +import "errors" + +// PartBoundary mirrors Node's PartBoundaries (client-file-uploader.ts:479). +// End is inclusive, like Node's createReadStream({start, end}) range. +type PartBoundary struct { + Start int64 + End int64 + Index int + PartSize int64 +} + +// GetPartBoundaries ports getPartBoundaries (client-file-uploader.ts:485). +func GetPartBoundaries(fileSize int64) ([]PartBoundary, error) { + return getPartBoundariesWithSize(fileSize, UploadPartSize) +} + +// getPartBoundariesWithSize is GetPartBoundaries with an explicit part +// size so tests don't need 16MB fixtures. +func getPartBoundariesWithSize(fileSize, partSize int64) ([]PartBoundary, error) { + if fileSize < 1 { + return nil, errors.New("fileSize must be greater than zero") + } + numParts := (fileSize + partSize - 1) / partSize + parts := make([]PartBoundary, 0, numParts) + for i := int64(0); i < numParts; i++ { + start := i * partSize + remaining := fileSize - start + end := start + partSize - 1 + if remaining <= partSize { + end = start + remaining - 1 + } + parts = append(parts, PartBoundary{ + Start: start, End: end, Index: int(i), PartSize: end + 1 - start, + }) + } + return parts, nil +} diff --git a/internal/upload/parts_test.go b/internal/upload/parts_test.go new file mode 100644 index 000000000..0ef9c6359 --- /dev/null +++ b/internal/upload/parts_test.go @@ -0,0 +1,39 @@ +package upload + +import "testing" + +func TestGetPartBoundaries(t *testing.T) { + // Node client-file-uploader.ts:485. UploadPartSize = 16 MiB. + const mb = int64(1024 * 1024) + cases := []struct { + name string + fileSize int64 + want []PartBoundary + }{ + {"one byte", 1, []PartBoundary{{Start: 0, End: 0, Index: 0, PartSize: 1}}}, + {"exactly one part", 16 * mb, []PartBoundary{{Start: 0, End: 16*mb - 1, Index: 0, PartSize: 16 * mb}}}, + {"one part plus one byte", 16*mb + 1, []PartBoundary{ + {Start: 0, End: 16*mb - 1, Index: 0, PartSize: 16 * mb}, + {Start: 16 * mb, End: 16 * mb, Index: 1, PartSize: 1}, + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := GetPartBoundaries(tc.fileSize) + if err != nil { + t.Fatal(err) + } + if len(got) != len(tc.want) { + t.Fatalf("len = %d, want %d", len(got), len(tc.want)) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("part %d = %+v, want %+v", i, got[i], tc.want[i]) + } + } + }) + } + if _, err := GetPartBoundaries(0); err == nil { + t.Error("fileSize 0 should error (Node: 'fileSize must be greater than zero')") + } +} diff --git a/internal/upload/presign.go b/internal/upload/presign.go new file mode 100644 index 000000000..3820a39f5 --- /dev/null +++ b/internal/upload/presign.go @@ -0,0 +1,103 @@ +package upload + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "os" + "time" + + json "encoding/json/v2" + + "github.com/Automattic/vip/internal/httpproxy" +) + +// Client issues presigned-request lookups against the VIP API and the +// resulting S3 uploads. APIHost/Token come from commands.GetConfig(); +// HTTPClient defaults to httpproxy.Client(). +type Client struct { + APIHost string + Token string + HTTPClient *http.Client + // retryDelay overrides the backoff in tests. nil = Node's + // 2^attempt * 1s (fetch-retry config, client-file-uploader.ts:24). + retryDelay func(attempt int) time.Duration +} + +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + // NOT http.DefaultClient: the presign call carries the bearer token (or + // WPVIP_DEPLOY_TOKEN) and the S3 PUTs carry a presigned URL whose query + // string is itself the credential. See internal/httpproxy. + return httpproxy.Client() +} + +// SignedRequestArgs ports GetSignedUploadRequestDataArgs +// (client-file-uploader.ts:56). EtagResults is the multipart completion +// payload: a list of {"ETag": ..., "PartNumber": ...} objects. +type SignedRequestArgs struct { + Action string `json:"action"` + AppID int64 `json:"appId"` + EnvID int64 `json:"envId"` + BaseName string `json:"basename"` + EtagResults []map[string]any `json:"etagResults,omitempty"` + PartNumber int `json:"partNumber,omitempty"` + UploadID string `json:"uploadId,omitempty"` +} + +// PresignedRequest mirrors Node's PresignedRequest +// (client-file-uploader.ts:236). +type PresignedRequest struct { + URL string `json:"url"` + Options struct { + Method string `json:"method"` + Headers map[string]string `json:"headers"` + Body string `json:"body,omitempty"` + } `json:"options"` +} + +// GetSignedUploadRequestData ports getSignedUploadRequestData +// (client-file-uploader.ts:411): POST /upload/site-import-presigned-url +// with the CLI token, or WPVIP_DEPLOY_TOKEN when set (ts:420 — the +// deploy-token bypass skips the keychain credential entirely). +func (c *Client) GetSignedUploadRequestData(ctx context.Context, args SignedRequestArgs) (*PresignedRequest, error) { + body, err := json.Marshal(args) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.APIHost+"/upload/site-import-presigned-url", bytes.NewReader(body)) + if err != nil { + return nil, err + } + token := c.Token + if t := os.Getenv("WPVIP_DEPLOY_TOKEN"); t != "" { + token = t + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + // Node: throw new Error((await response.text()) || statusText) + // — client-file-uploader.ts:433. + text, _ := io.ReadAll(resp.Body) + if len(text) > 0 { + return nil, fmt.Errorf("%s", text) + } + return nil, fmt.Errorf("%s", resp.Status) + } + var pr PresignedRequest + if err := json.UnmarshalRead(resp.Body, &pr); err != nil { + return nil, err + } + return &pr, nil +} diff --git a/internal/upload/presign_test.go b/internal/upload/presign_test.go new file mode 100644 index 000000000..388d51f61 --- /dev/null +++ b/internal/upload/presign_test.go @@ -0,0 +1,128 @@ +package upload + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + json "encoding/json/v2" +) + +func TestGetSignedUploadRequestData(t *testing.T) { + var gotBody map[string]any + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/upload/site-import-presigned-url" { + t.Errorf("path = %s", r.URL.Path) + } + gotAuth = r.Header.Get("Authorization") + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &gotBody) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"url":"https://s3.example/x","options":{"method":"PUT","headers":{"X-Amz-Meta":"1"}}}`)) + })) + defer srv.Close() + + c := &Client{APIHost: srv.URL, Token: "tok", HTTPClient: srv.Client()} + req, err := c.GetSignedUploadRequestData(context.Background(), SignedRequestArgs{ + Action: "PutObject", AppID: 1, EnvID: 2, BaseName: "dump.sql", + }) + if err != nil { + t.Fatal(err) + } + if req.URL != "https://s3.example/x" || req.Options.Method != "PUT" { + t.Errorf("req = %+v", req) + } + if req.Options.Headers["X-Amz-Meta"] != "1" { + t.Errorf("headers = %v", req.Options.Headers) + } + if gotAuth != "Bearer tok" { + t.Errorf("auth = %q", gotAuth) + } + if gotBody["action"] != "PutObject" || gotBody["basename"] != "dump.sql" { + t.Errorf("body = %v", gotBody) + } +} + +func TestGetSignedUploadRequestDataDeployTokenOverride(t *testing.T) { + t.Setenv("WPVIP_DEPLOY_TOKEN", "deploy-tok") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer deploy-tok" { + t.Errorf("auth = %q", got) + } + _, _ = w.Write([]byte(`{"url":"u","options":{"method":"PUT","headers":{}}}`)) + })) + defer srv.Close() + c := &Client{APIHost: srv.URL, Token: "tok", HTTPClient: srv.Client()} + if _, err := c.GetSignedUploadRequestData(context.Background(), SignedRequestArgs{ + Action: "PutObject", AppID: 1, EnvID: 2, BaseName: "x", + }); err != nil { + t.Fatal(err) + } +} + +func TestGetSignedUploadRequestDataNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "no can do", http.StatusForbidden) + })) + defer srv.Close() + c := &Client{APIHost: srv.URL, Token: "tok", HTTPClient: srv.Client()} + _, err := c.GetSignedUploadRequestData(context.Background(), SignedRequestArgs{ + Action: "PutObject", AppID: 1, EnvID: 2, BaseName: "x", + }) + // Node: throw new Error(await response.text() || statusText) — ts:433. + // http.Error appends a newline; the body is used verbatim. + if err == nil || err.Error() != "no can do\n" { + t.Errorf("err = %v", err) + } +} + +func TestDoWithRetryRetriesNetworkErrorsOnly(t *testing.T) { + attempts := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + attempts++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + c := &Client{HTTPClient: srv.Client(), retryDelay: func(int) time.Duration { return 0 }} + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + resp, err := c.doWithRetry(req, nil) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + // fetch-retry's default retryOn does NOT retry on HTTP status — only + // network errors. 500 must come back after exactly 1 attempt. + if attempts != 1 { + t.Errorf("attempts = %d, want 1 (no status-code retries)", attempts) + } + if resp.StatusCode != 500 { + t.Errorf("status = %d", resp.StatusCode) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestDoWithRetryNetworkErrorExhaustsAfter4(t *testing.T) { + attempts := 0 + c := &Client{ + HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + attempts++ + return nil, io.ErrUnexpectedEOF + })}, + retryDelay: func(int) time.Duration { return 0 }, + } + req, _ := http.NewRequest(http.MethodGet, "http://example.invalid", nil) + if _, err := c.doWithRetry(req, nil); err == nil { + t.Fatal("want error") + } + // retries: 3 → 4 total attempts (fetch-retry semantics) + if attempts != 4 { + t.Errorf("attempts = %d, want 4", attempts) + } +} diff --git a/internal/upload/proxy_test.go b/internal/upload/proxy_test.go new file mode 100644 index 000000000..a3f64c9f9 --- /dev/null +++ b/internal/upload/proxy_test.go @@ -0,0 +1,55 @@ +package upload + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "testing" +) + +// TestPresignRequestHonoursVIPProxy pins cutover item 2.14 on the second path +// that carries the bearer token: POST /upload/site-import-presigned-url. The +// default client was http.DefaultClient, which ignores VIP_PROXY/SOCKS_PROXY +// and honours HTTPS_PROXY without the VIP_USE_SYSTEM_PROXY opt-in Node requires. +// +// Live loopback target, closed SOCKS port: no Go proxy resolver would ever +// proxy a loopback host, so reaching the server proves the request went direct. +func TestPresignRequestHonoursVIPProxy(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"url":"https://s3.example/x","options":{"method":"PUT","headers":{}}}`)) + })) + defer srv.Close() + + for _, k := range []string{ + "SOCKS_PROXY", "socks_proxy", "HTTPS_PROXY", "https_proxy", + "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy", + "NO_PROXY", "no_proxy", "VIP_USE_SYSTEM_PROXY", "vip_proxy", + "WPVIP_DEPLOY_TOKEN", + } { + t.Setenv(k, "") + } + t.Setenv("VIP_PROXY", "socks5://"+closedProxyAddr(t)) + + c := &Client{APIHost: srv.URL, Token: "bearer-token-under-test"} + _, err := c.GetSignedUploadRequestData(context.Background(), SignedRequestArgs{ + Action: "AssertMultipartUpload", AppID: 1, EnvID: 2, BaseName: "x.sql", + }) + if err == nil { + t.Fatal("presign request succeeded; VIP_PROXY was ignored and the bearer token went direct") + } +} + +func closedProxyAddr(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := l.Addr().String() + if err := l.Close(); err != nil { + t.Fatalf("close: %v", err) + } + return addr +} diff --git a/internal/upload/putobject.go b/internal/upload/putobject.go new file mode 100644 index 000000000..cb6436477 --- /dev/null +++ b/internal/upload/putobject.go @@ -0,0 +1,89 @@ +package upload + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "sync/atomic" +) + +// progressReader counts bytes read and reports floor(100*read/total)% via +// cb — the PassThrough 'data' handler in Node (client-file-uploader.ts:277). +// read is shared across parts in multipart mode so the percentage reflects +// overall progress (ts:570 totalBytesRead). +type progressReader struct { + r io.Reader + total int64 + read *atomic.Int64 + cb func(percentage string) +} + +func (p *progressReader) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + if n > 0 && p.read != nil { + read := p.read.Add(int64(n)) + if p.cb != nil && p.total > 0 { + p.cb(fmt.Sprintf("%d%%", 100*read/p.total)) + } + } + return n, err +} + +type readCloser struct { + io.Reader + closer io.Closer +} + +func (rc readCloser) Close() error { return rc.closer.Close() } + +// uploadUsingPutObject ports uploadUsingPutObject +// (client-file-uploader.ts:255). Returns "ok" on HTTP 200, otherwise an +// error wrapping the S3 payload. +func (c *Client) uploadUsingPutObject(ctx context.Context, appID, envID int64, meta FileMeta, progressCb func(string)) (string, error) { + pre, err := c.GetSignedUploadRequestData(ctx, SignedRequestArgs{ + Action: "PutObject", AppID: appID, EnvID: envID, BaseName: meta.BaseName, + }) + if err != nil { + return "", err + } + + makeBody := func() (io.ReadCloser, error) { + f, err := os.Open(meta.FileName) // #nosec G304 + if err != nil { + return nil, err + } + var counter atomic.Int64 + return readCloser{ + Reader: &progressReader{r: f, total: meta.FileSize, read: &counter, cb: progressCb}, + closer: f, + }, nil + } + + body, err := makeBody() + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, pre.Options.Method, pre.URL, body) + if err != nil { + return "", err + } + for k, v := range pre.Options.Headers { + req.Header.Set(k, v) + } + // Node forces Content-Length as a string header (ts:273). + req.Header.Set("Content-Length", fmt.Sprintf("%d", meta.FileSize)) + req.ContentLength = meta.FileSize + + resp, err := c.doWithRetry(req, makeBody) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return "ok", nil + } + respBody, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("Unable to upload to cloud storage. %s", formatS3Error(respBody, resp)) +} diff --git a/internal/upload/putobject_test.go b/internal/upload/putobject_test.go new file mode 100644 index 000000000..b296d12ef --- /dev/null +++ b/internal/upload/putobject_test.go @@ -0,0 +1,79 @@ +package upload + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// stubPresignServer serves both the presign endpoint and the "S3" target. +func stubPresignServer(t *testing.T, s3Handler http.HandlerFunc) *Client { + t.Helper() + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + mux.HandleFunc("/upload/site-import-presigned-url", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"url":"` + srv.URL + `/s3target","options":{"method":"PUT","headers":{}}}`)) + }) + mux.HandleFunc("/s3target", s3Handler) + return &Client{APIHost: srv.URL, Token: "tok", HTTPClient: srv.Client()} +} + +func TestUploadUsingPutObjectOK(t *testing.T) { + var gotLen string + var gotBody []byte + c := stubPresignServer(t, func(w http.ResponseWriter, r *http.Request) { + gotLen = r.Header.Get("Content-Length") + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + }) + p := writeTemp(t, "small.sql", []byte("SELECT 1;\n")) + meta, _ := GetFileMeta(p) + var lastPct string + result, err := c.uploadUsingPutObject(context.Background(), 1, 2, meta, + func(pct string) { lastPct = pct }) + if err != nil { + t.Fatal(err) + } + if result != "ok" { + t.Errorf("result = %q", result) + } + if gotLen != "10" || string(gotBody) != "SELECT 1;\n" { + t.Errorf("len=%q body=%q", gotLen, gotBody) + } + if lastPct != "100%" { + t.Errorf("last pct = %q", lastPct) + } +} + +func TestUploadUsingPutObjectS3Error(t *testing.T) { + c := stubPresignServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`AccessDeniedDenied`)) + }) + p := writeTemp(t, "small.sql", []byte("SELECT 1;\n")) + meta, _ := GetFileMeta(p) + _, err := c.uploadUsingPutObject(context.Background(), 1, 2, meta, nil) + want := `Unable to upload to cloud storage. {"Code":"AccessDenied","Message":"Denied"}` + if err == nil || err.Error() != want { + t.Errorf("err = %v\nwant %s", err, want) + } +} + +func TestUploadUsingPutObjectNonXMLError(t *testing.T) { + c := stubPresignServer(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("upstream had a bad day")) + }) + p := writeTemp(t, "small.sql", []byte("SELECT 1;\n")) + meta, _ := GetFileMeta(p) + _, err := c.uploadUsingPutObject(context.Background(), 1, 2, meta, nil) + // Node falls back to {Code: "HTTP Error ", Message: statusText} + // when the body isn't an doc (ts:315-320). + if err == nil || !strings.Contains(err.Error(), "HTTP Error 502") { + t.Errorf("err = %v", err) + } +} diff --git a/internal/upload/retry.go b/internal/upload/retry.go new file mode 100644 index 000000000..042dd844e --- /dev/null +++ b/internal/upload/retry.go @@ -0,0 +1,45 @@ +package upload + +import ( + "io" + "net/http" + "time" +) + +// maxRetries mirrors fetch-retry's `retries: 3` (client-file-uploader.ts:23). +const maxRetries = 3 + +// doWithRetry replicates fetch-retry's defaults as configured in Node: +// retry on transport (network) errors only — NOT on HTTP status codes +// (fetch-retry's default retryOn is empty) — up to maxRetries extra +// attempts, sleeping 2^attempt seconds between tries (1s, 2s, 4s; +// client-file-uploader.ts:24). makeBody, when non-nil, recreates the +// request body before each retry attempt (streaming bodies are consumed +// by failed attempts). +func (c *Client) doWithRetry(req *http.Request, makeBody func() (io.ReadCloser, error)) (*http.Response, error) { + delay := c.retryDelay + if delay == nil { + delay = func(attempt int) time.Duration { + return time.Duration(1< 0 { + time.Sleep(delay(attempt - 1)) + if makeBody != nil { + body, err := makeBody() + if err != nil { + return nil, err + } + req.Body = body + } + } + resp, err := c.httpClient().Do(req) + if err == nil { + return resp, nil + } + lastErr = err + } + return nil, lastErr +} diff --git a/internal/upload/upload.go b/internal/upload/upload.go new file mode 100644 index 000000000..eb4b33d8b --- /dev/null +++ b/internal/upload/upload.go @@ -0,0 +1,22 @@ +// Package upload ports src/lib/client-file-uploader.ts: streamed S3 +// uploads via presigned requests obtained from the VIP API. Strict Node +// parity: no resume cache; 3 network-error retries with 1s/2s/4s backoff; +// gzip-compress files >= CompressThreshold before upload; PutObject below +// MultipartThreshold, S3 multipart at/above it with 5 concurrent part +// workers. +package upload + +const ( + mbInBytes = 1024 * 1024 + + // CompressThreshold — client-file-uploader.ts:32. Files at/above this + // size that are not already compressed get gzipped before upload. + CompressThreshold = 16 * mbInBytes + // MultipartThreshold — client-file-uploader.ts:35. Files below this + // size use PutObject; at/above use the S3 multipart API. + MultipartThreshold = 32 * mbInBytes + // UploadPartSize — client-file-uploader.ts:38. + UploadPartSize = 16 * mbInBytes + // MaxConcurrentPartUploads — client-file-uploader.ts:41. + MaxConcurrentPartUploads = 5 +) diff --git a/internal/upload/xmlerror.go b/internal/upload/xmlerror.go new file mode 100644 index 000000000..493c027ec --- /dev/null +++ b/internal/upload/xmlerror.go @@ -0,0 +1,28 @@ +package upload + +import ( + "encoding/xml" + "fmt" + "net/http" +) + +// s3Error is the body S3 returns on failure (Node parses with xml2js; +// only Code and Message are consumed — client-file-uploader.ts:246). +type s3Error struct { + XMLName xml.Name `xml:"Error"` + Code string `xml:"Code"` + Message string `xml:"Message"` +} + +// formatS3Error renders the {"Code":...,"Message":...} fragment Node +// builds with JSON.stringify({ Code, Message }) — client-file-uploader.ts:322. +func formatS3Error(body []byte, resp *http.Response) string { + var e s3Error + if err := xml.Unmarshal(body, &e); err == nil && e.Code != "" { + return fmt.Sprintf(`{"Code":%q,"Message":%q}`, e.Code, e.Message) + } + // Node: Code = `HTTP Error `, Message = statusText (ts:318). + return fmt.Sprintf(`{"Code":%q,"Message":%q}`, + fmt.Sprintf("HTTP Error %d", resp.StatusCode), + http.StatusText(resp.StatusCode)) +} diff --git a/internal/validatefiles/files.go b/internal/validatefiles/files.go new file mode 100644 index 000000000..f43e70270 --- /dev/null +++ b/internal/validatefiles/files.go @@ -0,0 +1,105 @@ +package validatefiles + +import ( + "os" + "path/filepath" + "regexp" + "strings" +) + +// FileValidationResult mirrors ValidationResult (ts:36). +type FileValidationResult struct { + IntermediateImagesTotal int + ErrorFileTypes []string + ErrorFileNames []string + ErrorFileSizes []string + ErrorFileNamesCharCount []string + IntermediateImages map[string]string // original -> "im1, im2" +} + +// ValidateFiles ports validateFiles (ts:50): per-file extension, size, +// sanitized-name, name-length, and intermediate-image checks. +func ValidateFiles(files []string, cfg Config) FileValidationResult { + res := FileValidationResult{IntermediateImages: map[string]string{}} + for _, file := range files { + fi, statErr := os.Stat(file) + isFolder := statErr == nil && fi.IsDir() + + ext, typ := getExtAndType(file, cfg.AllowedFileTypes) + // isInvalidFile (ts:114): no type, no ext, or a folder. + if typ == "" || ext == "" || isFolder { + res.ErrorFileTypes = append(res.ErrorFileTypes, file) + } + + // isFileSizeValid (ts:137): limit >= size. + if statErr == nil && cfg.FileSizeLimitInBytes < fi.Size() { + res.ErrorFileSizes = append(res.ErrorFileSizes, file) + } + + if IsFileSanitized(file) { + res.ErrorFileNames = append(res.ErrorFileNames, file) + } + + // isFileNameCharCountValid (ts:142): len(basename) <= limit. + if int64(len(filepath.Base(file))) > cfg.FileNameCharCount { + res.ErrorFileNamesCharCount = append(res.ErrorFileNamesCharCount, file) + } + + if original, ok := DoesImageHaveExistingSource(file); ok { + res.IntermediateImagesTotal++ + if existing, found := res.IntermediateImages[original]; found { + res.IntermediateImages[original] = existing + ", " + file + } else { + res.IntermediateImages[original] = file + } + } + } + return res +} + +// getExtAndType ports getExtAndType (ts:118): first allowed-type key +// whose `(?:\.)()$` regex (case-insensitive) matches wins. +func getExtAndType(filePath string, allowed map[string]string) (ext, typ string) { + for key, value := range allowed { + re, err := regexp.Compile(`(?i)(?:\.)(` + key + `)$`) + if err != nil { + continue + } + if m := re.FindStringSubmatch(filePath); m != nil { + return m[1], value + } + } + return "", "" +} + +// sanitizeSpacesRE — ts:648's / |(%20)|\+/g. +var sanitizeSpacesRE = regexp.MustCompile(`\x{00A0}|(%20)|\+`) + +// IsFileSanitized ports isFileSanitized (ts:641): the name is flagged +// when converting encoded/alternate whitespace to spaces changes it. +func IsFileSanitized(file string) bool { + filename := filepath.Base(file) + sanitized := sanitizeSpacesRE.ReplaceAllString(filename, " ") + return sanitized != filename +} + +// intermediateImageRE — ts:672's /([_-])?(\d+x\d+)(@\d+\w)?(\.\w{3,4})$/. +var intermediateImageRE = regexp.MustCompile(`([_-])?(\d+x\d+)(@\d+\w)?(\.\w{3,4})$`) + +// DoesImageHaveExistingSource ports doesImageHaveExistingSource (ts:677): +// when the filename looks like an intermediate image AND the original +// (sizing stripped) exists on disk, return the original's path. +func DoesImageHaveExistingSource(file string) (string, bool) { + filename := filepath.Base(file) + m := intermediateImageRE.FindString(filename) + if m == "" { + return "", false + } + extension := strings.TrimPrefix(filepath.Ext(filename), ".") + baseFileName := strings.Replace(filename, m, "", 1) + "." + extension + originalImage := filepath.Join(filepath.Dir(file), baseFileName) + if _, err := os.Stat(originalImage); err == nil { + return originalImage, true + } + return "", false +} diff --git a/internal/validatefiles/files_test.go b/internal/validatefiles/files_test.go new file mode 100644 index 000000000..033360c80 --- /dev/null +++ b/internal/validatefiles/files_test.go @@ -0,0 +1,143 @@ +package validatefiles + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestIsFileSanitized(t *testing.T) { + for name, want := range map[string]bool{ + "a+b.jpg": true, + "a%20b.jpg": true, + "a b.jpg": false, // plain space is fine + "a b.jpg": true, // no-break space + "clean.jpg": false, + } { + if got := IsFileSanitized(name); got != want { + t.Errorf("IsFileSanitized(%q) = %v, want %v", name, got, want) + } + } +} + +func TestDoesImageHaveExistingSource(t *testing.T) { + dir := t.TempDir() + mk := func(name string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + return p + } + original := mk("panda.jpg") + intermediate := mk("panda-4000x6000.jpg") + orphan := mk("lonely-300x200.jpg") + retinaOriginal := mk("panda_test.jpg") + retina := mk("panda_test-4000x6000@2x.jpg") + _ = retinaOriginal + + if got, ok := DoesImageHaveExistingSource(intermediate); !ok || got != original { + t.Errorf("intermediate: got %q ok=%v", got, ok) + } + if _, ok := DoesImageHaveExistingSource(orphan); ok { + t.Error("orphan intermediate must not match (no original on disk)") + } + if got, ok := DoesImageHaveExistingSource(retina); !ok || !strings.HasSuffix(got, "panda_test.jpg") { + t.Errorf("retina: got %q ok=%v", got, ok) + } + if _, ok := DoesImageHaveExistingSource(original); ok { + t.Error("original is not an intermediate image") + } +} + +func TestValidateFiles(t *testing.T) { + dir := t.TempDir() + mk := func(name, content string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return p + } + good := mk("good.jpg", "x") + badExt := mk("script.exe", "x") + tooBig := mk("big.jpg", strings.Repeat("x", 50)) + badName := mk("a+b.jpg", "x") + longName := mk(strings.Repeat("n", 30)+".jpg", "x") + original := mk("img.png", "x") + intermediate := mk("img-100x100.png", "x") + + cfg := Config{ + FileNameCharCount: 20, + FileSizeLimitInBytes: 40, + AllowedFileTypes: map[string]string{"jpg": "image/jpeg", "png": "image/png"}, + } + res := ValidateFiles([]string{good, badExt, tooBig, badName, longName, original, intermediate}, cfg) + + if len(res.ErrorFileTypes) != 1 || res.ErrorFileTypes[0] != badExt { + t.Errorf("ErrorFileTypes = %v", res.ErrorFileTypes) + } + if len(res.ErrorFileSizes) != 1 || res.ErrorFileSizes[0] != tooBig { + t.Errorf("ErrorFileSizes = %v", res.ErrorFileSizes) + } + if len(res.ErrorFileNames) != 1 || res.ErrorFileNames[0] != badName { + t.Errorf("ErrorFileNames = %v", res.ErrorFileNames) + } + if len(res.ErrorFileNamesCharCount) != 1 || res.ErrorFileNamesCharCount[0] != longName { + t.Errorf("ErrorFileNamesCharCount = %v", res.ErrorFileNamesCharCount) + } + if res.IntermediateImagesTotal != 1 || res.IntermediateImages[original] != intermediate { + t.Errorf("IntermediateImages = %v (total %d)", res.IntermediateImages, res.IntermediateImagesTotal) + } +} + +func TestSummaryLogsAllPass(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var buf bytes.Buffer + SummaryLogs(&buf, SummaryParams{TotalFiles: 10, TotalFolders: 3}) + out := buf.String() + if strings.Contains(out, "ERROR") || strings.Contains(out, "RECOMMENDED") { + t.Errorf("all-pass summary contains failures: %q", out) + } + if strings.Count(out, "PASS") != 6 { + t.Errorf("want 6 PASS lines, got %d in %q", strings.Count(out, "PASS"), out) + } +} + +func TestSummaryLogsWithErrors(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var buf bytes.Buffer + SummaryLogs(&buf, SummaryParams{ + FolderErrorsLength: 2, + FileTypeErrorsLength: 3, + TotalFiles: 10, + TotalFolders: 5, + }) + out := buf.String() + if !strings.Contains(out, "RECOMMENDED") || !strings.Contains(out, "2 folders, 5 folders total") { + t.Errorf("folder line wrong: %q", out) + } + if !strings.Contains(out, "3 invalid file extensions") { + t.Errorf("extension line wrong: %q", out) + } + // Node bug parity (ts:833): sizes line shows fileTypeErrorsLength. + if !strings.Contains(out, "3 invalid file sizes") { + t.Errorf("sizes line must reuse fileTypeErrorsLength (Node bug): %q", out) + } +} + +func TestLogErrorsInvalidNames(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var buf bytes.Buffer + LogErrors(&buf, LogErrorsOptions{ + ErrorType: ErrInvalidNames, + InvalidFiles: []string{"a+b.jpg"}, + }) + out := buf.String() + if !strings.Contains(out, "Character validation: Invalid filename for file: ") || + !strings.Contains(out, "The following characters are allowed in file names:") { + t.Errorf("out = %q", out) + } +} diff --git a/internal/validatefiles/report.go b/internal/validatefiles/report.go new file mode 100644 index 000000000..338759774 --- /dev/null +++ b/internal/validatefiles/report.go @@ -0,0 +1,172 @@ +package validatefiles + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/fatih/color" +) + +// Error types — ValidateFilesErrors (ts:13). +const ( + ErrInvalidTypes = "invalid_types" + ErrIntermediateImages = "intermediate_images" + ErrInvalidSizes = "invalid_sizes" + ErrInvalidNames = "invalid_names" + ErrInvalidNameCharCounts = "invalid_name_character_counts" +) + +// acceptedCharacters — ts:159 (Set-deduplicated; the duplicate backtick +// in the Node literal collapses). +var acceptedCharacters = []string{ + "Non-English characters", "(", ")", "[", "]", "~", "&", "#", "%", "=", + "’", "'", "×", "@", "`", "?", "*", "!", "\"", "\\", "<", ">", ":", + ";", ",", "/", "$", "|", "{", "}", "spaces", +} + +// prohibitedCharacters — ts:196. +var prohibitedCharacters = []string{"+", "%20"} + +// recommendAcceptableFileTypes ports ts:225. +func recommendAcceptableFileTypes(w io.Writer, allowedFileTypes string) { + fmt.Fprintln(w, "Accepted file types: \n\n"+color.MagentaString(allowedFileTypes)) + fmt.Fprintln(w) +} + +// recommendAcceptableFileNames ports ts:231. +func recommendAcceptableFileNames(w io.Writer) { + allowed := strings.Join(acceptedCharacters, " ") + notAllowed := strings.Join(prohibitedCharacters, " ") + fmt.Fprintln(w, + "The following characters are allowed in file names:\n"+ + color.GreenString("All special characters, including: "+allowed+"\n\n")+ + "The following characters are prohibited in file names:\n"+ + color.RedString("Encoded or alternate whitespace, such as "+notAllowed+", are converted to proper spaces\n")) +} + +// LogErrorsOptions mirrors LogErrorOptions (ts:21). AllowedTypes feeds +// the invalid-types recommendation; Limit the size/char-count messages; +// IntermediateImages the duplicate-files detail. +type LogErrorsOptions struct { + ErrorType string + InvalidFiles []string + AllowedTypes []string + Limit int64 + IntermediateImages map[string]string +} + +// LogErrors ports logErrors (ts:709). +func LogErrors(w io.Writer, o LogErrorsOptions) { + if len(o.InvalidFiles) == 0 { + return + } + for _, file := range o.InvalidFiles { + switch o.ErrorType { + case ErrInvalidTypes: + fmt.Fprintln(w, color.RedString("✕"), "File extensions: Invalid file type for file: ", + color.CyanString(file)) + fmt.Fprintln(w) + recommendAcceptableFileTypes(w, strings.Join(o.AllowedTypes, ",")) + case ErrIntermediateImages: + fmt.Fprintln(w, color.RedString("✕"), + "Intermediate images: Duplicate files found:\n"+ + "Original file: "+color.BlueString(file+"\n")+ + "Intermediate images: "+color.CyanString(o.IntermediateImages[file]+"\n")) + case ErrInvalidSizes: + fmt.Fprintln(w, color.RedString("✕"), + fmt.Sprintf("File size cannot be more than %g GB", float64(o.Limit)/1024/1024/1024), + color.CyanString(file)) + fmt.Fprintln(w) + case ErrInvalidNameCharCounts: + fmt.Fprintln(w, color.RedString("✕"), + fmt.Sprintf("File name cannot have more than %d characters", o.Limit), + color.CyanString(file)) + case ErrInvalidNames: + fmt.Fprintln(w, color.RedString("✕"), "Character validation: Invalid filename for file: ", + color.CyanString(file)) + recommendAcceptableFileNames(w) + default: + fmt.Fprintln(w, color.RedString("✕"), "Unknown error type:", o.ErrorType) + } + } + fmt.Fprintln(w) +} + +// SortedKeys returns map keys sorted — Object.keys order in Node is +// insertion order, which a Go map can't reproduce; sorted keeps output +// deterministic for tests and humans. +func SortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// SummaryParams mirrors SummaryLogsParams (ts:766). +type SummaryParams struct { + FolderErrorsLength int + IntImagesErrorsLength int + FileTypeErrorsLength int + FileErrorFileSizesLength int + FilenameErrorsLength int + FileNameCharCountErrorsLength int + TotalFiles int + TotalFolders int +} + +// SummaryLogs ports summaryLogs (ts:777). Two Node copy bugs are kept +// deliberately: the sizes line prints fileTypeErrorsLength (ts:833) and +// the char-count line prints filenameErrorsLength (ts:862). +func SummaryLogs(w io.Writer, p SummaryParams) { + var messages []string + + if p.FolderErrorsLength > 0 { + messages = append(messages, color.New(color.BgYellow).Sprint(" RECOMMENDED ")+ + color.New(color.Bold, color.FgYellow).Sprintf(" %d folders, ", p.FolderErrorsLength)+ + fmt.Sprintf("%d folders total", p.TotalFolders)) + } else { + messages = append(messages, color.New(color.BgGreen).Sprint(" PASS ")+ + color.New(color.Bold, color.FgGreen).Sprintf(" %d folders, ", p.TotalFolders)+ + fmt.Sprintf("%d folders total", p.TotalFolders)) + } + + badge := func(bad bool) string { + if bad { + return color.New(color.FgWhite, color.BgRed).Sprint(" ERROR ") + } + return color.New(color.FgWhite, color.BgGreen).Sprint(" PASS ") + } + line := func(bad bool, detail string) string { + colored := color.GreenString(detail) + if bad { + colored = color.RedString(detail) + } + return badge(bad) + colored + fmt.Sprintf(", %d files total", p.TotalFiles) + } + + messages = append(messages, line(p.IntImagesErrorsLength > 0, + fmt.Sprintf(" %d intermediate images", p.IntImagesErrorsLength))) + messages = append(messages, line(p.FileTypeErrorsLength > 0, + fmt.Sprintf(" %d invalid file extensions", p.FileTypeErrorsLength))) + // Node bug (ts:833): prints fileTypeErrorsLength in the sizes line. + messages = append(messages, line(p.FileErrorFileSizesLength > 0, + fmt.Sprintf(" %d invalid file sizes", p.FileTypeErrorsLength))) + messages = append(messages, line(p.FilenameErrorsLength > 0, + fmt.Sprintf(" %d invalid filenames", p.FilenameErrorsLength))) + // Node bug (ts:862): prints filenameErrorsLength in the char-count line. + if p.FileNameCharCountErrorsLength > 0 { + messages = append(messages, badge(true)+ + color.RedString(fmt.Sprintf(" %d file names reached the maximum character count limit ", p.FilenameErrorsLength))+ + fmt.Sprintf(", %d files total", p.TotalFiles)) + } else { + messages = append(messages, color.New(color.BgGreen).Sprint(" PASS ")+ + color.GreenString(fmt.Sprintf(" %d file names reached the maximum character count limit", p.FilenameErrorsLength))+ + fmt.Sprintf(", %d files total", p.TotalFiles)) + } + + fmt.Fprintf(w, "\n%s\n\n", strings.Join(messages, "\n")) +} diff --git a/internal/validatefiles/validatefiles.go b/internal/validatefiles/validatefiles.go new file mode 100644 index 000000000..d7ad80afc --- /dev/null +++ b/internal/validatefiles/validatefiles.go @@ -0,0 +1,271 @@ +// Package validatefiles ports src/lib/vip-import-validate-files.ts (877 +// LOC): the local directory walk, WordPress folder-structure validation, +// per-file checks, and the error/summary reports printed by +// `vip import validate-files`. All output flows through injected +// io.Writers so the command wires stdout/stderr and tests capture +// buffers. +package validatefiles + +import ( + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/fatih/color" +) + +// Config mirrors MediaImportConfig (graphqlTypes) as consumed by +// validateFiles (ts:50). +type Config struct { + FileNameCharCount int64 + FileSizeLimitInBytes int64 + AllowedFileTypes map[string]string // ext -> type label +} + +// WalkResult mirrors findNestedDirectories' return (ts:261). +type WalkResult struct { + Files []string + Folders []string // directories that directly contain files, in walk order +} + +// hiddenFileRE — ts:276's /(^|\/)\.[^/.]/. +var hiddenFileRE = regexp.MustCompile(`(^|/)\.[^/.]`) + +// FindNestedDirectories ports findNestedDirectories (ts:266): recursive +// walk collecting leaf files and the set of directories that directly +// contain files. Hidden entries are filtered. Read errors print the Node +// message to errW and return nil (ts:295-302). +func FindNestedDirectories(directory string, errW io.Writer) *WalkResult { + res := &WalkResult{} + seenFolder := map[string]bool{} + if !walkNested(directory, errW, res, seenFolder) { + return nil + } + return res +} + +func walkNested(directory string, errW io.Writer, res *WalkResult, seenFolder map[string]bool) bool { + entries, err := os.ReadDir(directory) + if err != nil { + fmt.Fprintln(errW, color.RedString("✕"), + fmt.Sprintf(" Error: Cannot read nested directory: %s. Reason: %s", directory, err.Error())) + return false + } + for _, entry := range entries { + if hiddenFileRE.MatchString(entry.Name()) { + continue + } + filePath := filepath.Join(directory, entry.Name()) + if entry.IsDir() { + // Node ignores the recursive call's failure (it only aborts + // the top-level call); mirror by continuing on sub-failure. + walkNested(filePath, errW, res, seenFolder) + continue + } + if !seenFolder[directory] { + seenFolder[directory] = true + res.Folders = append(res.Folders, directory) + } + res.Files = append(res.Files, filePath) + } + return true +} + +// indexPositions mirrors getIndexPositionOfFolders (ts:330). +type indexPositions struct { + uploadsIndex int // -1 when absent (Node indexOf semantics) + sitesIndex int + siteIDIndex int + yearIndex int + monthIndex int + hasSiteID bool + hasYear bool + hasMonth bool +} + +var ( + regexSiteID = regexp.MustCompile(`/sites/(\d+)`) + regexYear = regexp.MustCompile(`\b\d{4}\b`) + regexMonth = regexp.MustCompile(`\b\d{2}\b`) +) + +func getIndexPositionOfFolders(folderPath string, sites bool) indexPositions { + pos := indexPositions{uploadsIndex: -1, sitesIndex: -1, siteIDIndex: -1} + pathMutate := folderPath + directories := strings.Split(pathMutate, "/") + + pos.uploadsIndex = indexOf(directories, "uploads") + + if sites { + pos.sitesIndex = indexOf(directories, "sites") + if m := regexSiteID.FindStringSubmatch(pathMutate); m != nil { + pos.siteIDIndex = indexOf(directories, m[1]) + pos.hasSiteID = true + // ts:367 — strip the multisite segment so a 2-digit site ID + // isn't confused with the month. + pathMutate = strings.Replace(pathMutate, m[0], "", 1) + } + } + + if m := regexYear.FindString(pathMutate); m != "" { + pos.yearIndex = indexOf(directories, m) + pos.hasYear = true + } + if m := regexMonth.FindString(pathMutate); m != "" { + pos.monthIndex = indexOf(directories, m) + pos.hasMonth = true + } + return pos +} + +func indexOf(list []string, v string) int { + for i, s := range list { + if s == v { + return i + } + } + return -1 +} + +// singleSiteValidation ports singleSiteValidation (ts:428). Returns the +// folder path when it has structure errors, "" otherwise. +func singleSiteValidation(folderPath string, w io.Writer) string { + errs := 0 + fmt.Fprintln(w, color.New(color.Bold).Sprint("Folder:"), color.CyanString(folderPath)) + pos := getIndexPositionOfFolders(folderPath, false) + + if pos.uploadsIndex == 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "✅ File structure: Uploads directory exists") + } else { + fmt.Fprintln(w) + fmt.Fprintln(w, color.YellowString("✕"), "Recommended: Media files should reside in an", + color.MagentaString("`uploads`"), "directory") + errs++ + } + + // Node: `if (yearIndex && yearIndex === 1)` — index 0 would be falsy, + // but uploads occupies 0 in valid layouts so === 1 is the real gate. + if pos.hasYear && pos.yearIndex == 1 { + fmt.Fprintln(w, "✅ File structure: Year directory exists (format: YYYY)") + } else { + fmt.Fprintln(w, color.YellowString("✕"), "Recommended: Structure your WordPress media files into", + color.MagentaString("`uploads/YYYY`"), "directories") + errs++ + } + + if pos.hasMonth && pos.monthIndex == 2 { + fmt.Fprintln(w, "✅ File structure: Month directory exists (format: MM)") + fmt.Fprintln(w) + } else { + fmt.Fprintln(w, color.YellowString("✕"), "Recommended: Structure your WordPress media files into", + color.MagentaString("`uploads/YYYY/MM`"), "directories") + fmt.Fprintln(w) + errs++ + } + + if errs > 0 { + return folderPath + } + return "" +} + +// multiSiteValidation ports multiSiteValidation (ts:504). +func multiSiteValidation(folderPath string, w io.Writer) string { + errs := 0 + fmt.Fprintln(w, color.New(color.Bold).Sprint("Folder:"), color.CyanString(folderPath)) + pos := getIndexPositionOfFolders(folderPath, true) + + if pos.uploadsIndex == 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "✅ File structure: Uploads directory exists") + } else { + fmt.Fprintln(w) + fmt.Fprintln(w, color.YellowString("✕"), "Recommended: Media files should reside in an", + color.MagentaString("`uploads`"), "directory") + errs++ + } + + if pos.sitesIndex == 1 { + fmt.Fprintln(w, "✅ File structure: Sites directory exists") + } else { + fmt.Fprintln(w) + fmt.Fprintln(w, color.YellowString("✕"), "Recommended: Media files should reside in an", + color.MagentaString("`sites`"), "directory") + errs++ + } + + if pos.hasSiteID && pos.siteIDIndex == 2 { + fmt.Fprintln(w, "✅ File structure: Site ID directory exists") + } else { + fmt.Fprintln(w, color.YellowString("✕"), "Recommended: Structure your WordPress media files into", + color.MagentaString("`uploads/sites/`"), "directories") + errs++ + } + + if pos.hasYear && pos.yearIndex == 3 { + fmt.Fprintln(w, "✅ File structure: Year directory exists (format: YYYY)") + } else { + fmt.Fprintln(w, color.YellowString("✕"), "Recommended: Structure your WordPress media files into", + color.MagentaString("`uploads/sites//YYYY`"), "directories") + errs++ + } + + if pos.hasMonth && pos.monthIndex == 4 { + fmt.Fprintln(w, "✅ File structure: Month directory exists (format: MM)") + fmt.Fprintln(w) + } else { + fmt.Fprintln(w, color.YellowString("✕"), "Recommended: Structure your WordPress media files into", + color.MagentaString("`uploads/sites//YYYY/MM`"), "directories") + fmt.Fprintln(w) + errs++ + } + + if errs > 0 { + return folderPath + } + return "" +} + +// FolderStructureValidation ports folderStructureValidation (ts:603): +// validate each folder (multisite when the path contains "sites"), +// returning the offending paths; prints the recommended-structure block +// when any folder failed. +func FolderStructureValidation(folders []string, w io.Writer) []string { + var allErrors []string + for _, folderPath := range folders { + var bad string + if strings.Contains(folderPath, "sites") { + bad = multiSiteValidation(folderPath, w) + } else { + bad = singleSiteValidation(folderPath, w) + } + if bad != "" { + allErrors = append(allErrors, bad) + } + } + if len(allErrors) > 0 { + recommendedFileStructure(w) + } + return allErrors +} + +// recommendedFileStructure ports recommendedFileStructure (ts:206). +func recommendedFileStructure(w io.Writer) { + underline := color.New(color.Underline) + fmt.Fprintln(w, + underline.Sprint("We recommend the WordPress default folder structure for your media files: \n\n")+ + underline.Sprint("Single sites:")+ + color.YellowString("`uploads/year/month/image.png`\n")+ + " e.g.-"+ + color.YellowString("`uploads/2020/06/image.png`\n")+ + underline.Sprint("Multisites:")+ + color.CyanString("`uploads/sites/siteID/year/month/image.png`\n")+ + " e.g.-"+ + color.CyanString("`uploads/sites/5/2020/06/images.png`\n")) + fmt.Fprintln(w, "------------------------------------------------------------") + fmt.Fprintln(w) +} diff --git a/internal/validatefiles/validatefiles_test.go b/internal/validatefiles/validatefiles_test.go new file mode 100644 index 000000000..3607ac224 --- /dev/null +++ b/internal/validatefiles/validatefiles_test.go @@ -0,0 +1,127 @@ +package validatefiles + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func mkTree(t *testing.T, root string, files []string) { + t.Helper() + for _, f := range files { + p := filepath.Join(root, f) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } +} + +func TestFindNestedDirectories(t *testing.T) { + root := t.TempDir() + mkTree(t, root, []string{ + "uploads/2020/06/a.jpg", + "uploads/2020/06/b.png", + "uploads/2020/06/.DS_Store", // hidden — filtered (ts:276) + "uploads/2020/07/c.gif", + }) + res := FindNestedDirectories(filepath.Join(root, "uploads"), &bytes.Buffer{}) + if res == nil { + t.Fatal("walk failed") + } + if len(res.Files) != 3 { + t.Errorf("files = %v", res.Files) + } + if len(res.Folders) != 2 { + t.Errorf("folders = %v", res.Folders) + } + for _, f := range res.Files { + if strings.Contains(f, ".DS_Store") { + t.Errorf("hidden file leaked: %s", f) + } + } +} + +func TestFindNestedDirectoriesUnreadable(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("root ignores permissions") + } + root := t.TempDir() + locked := filepath.Join(root, "locked") + if err := os.MkdirAll(locked, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(locked, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(locked, 0o755) }) + + var errBuf bytes.Buffer + res := FindNestedDirectories(locked, &errBuf) + if res != nil { + t.Error("unreadable top-level dir must return nil") + } + if !strings.Contains(errBuf.String(), "Error: Cannot read nested directory: "+locked) { + t.Errorf("errW = %q", errBuf.String()) + } +} + +func TestFolderStructureValidationSingleSiteGood(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var buf bytes.Buffer + bad := FolderStructureValidation([]string{"uploads/2020/06"}, &buf) + if len(bad) != 0 { + t.Errorf("bad = %v\n%s", bad, buf.String()) + } + out := buf.String() + for _, want := range []string{ + "✅ File structure: Uploads directory exists", + "✅ File structure: Year directory exists (format: YYYY)", + "✅ File structure: Month directory exists (format: MM)", + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q in %q", want, out) + } + } +} + +func TestFolderStructureValidationSingleSiteBad(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var buf bytes.Buffer + bad := FolderStructureValidation([]string{"media/stuff"}, &buf) + if len(bad) != 1 || bad[0] != "media/stuff" { + t.Errorf("bad = %v", bad) + } + out := buf.String() + if !strings.Contains(out, "Recommended: Media files should reside in an `uploads` directory") { + t.Errorf("missing uploads recommendation: %q", out) + } + if !strings.Contains(out, "We recommend the WordPress default folder structure") { + t.Errorf("missing recommended-structure block: %q", out) + } +} + +func TestFolderStructureValidationMultisiteGood(t *testing.T) { + t.Setenv("NO_COLOR", "1") + var buf bytes.Buffer + bad := FolderStructureValidation([]string{"uploads/sites/5/2020/06"}, &buf) + if len(bad) != 0 { + t.Errorf("bad = %v\n%s", bad, buf.String()) + } + out := buf.String() + for _, want := range []string{ + "✅ File structure: Uploads directory exists", + "✅ File structure: Sites directory exists", + "✅ File structure: Site ID directory exists", + "✅ File structure: Year directory exists (format: YYYY)", + "✅ File structure: Month directory exists (format: MM)", + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q in %q", want, out) + } + } +} From d7aa5ae45b060dd504ea6734b98a63c8b25644ef Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 20 Aug 2026 18:36:41 -0500 Subject: [PATCH 12/32] feat(go): backup, custom deploy and environment sync Ported from vip-cli-golang@72ef2f89 (feature/go-rewrite). --- internal/backup/backup.go | 194 ++++++++++++++++ internal/backup/backup_test.go | 228 +++++++++++++++++++ internal/customdeploy/archive.go | 205 +++++++++++++++++ internal/customdeploy/archive_test.go | 174 ++++++++++++++ internal/customdeploy/customdeploy.go | 91 ++++++++ internal/customdeploy/customdeploy_test.go | 82 +++++++ internal/sync/sync.go | 246 ++++++++++++++++++++ internal/sync/sync_test.go | 253 +++++++++++++++++++++ 8 files changed, 1473 insertions(+) create mode 100644 internal/backup/backup.go create mode 100644 internal/backup/backup_test.go create mode 100644 internal/customdeploy/archive.go create mode 100644 internal/customdeploy/archive_test.go create mode 100644 internal/customdeploy/customdeploy.go create mode 100644 internal/customdeploy/customdeploy_test.go create mode 100644 internal/sync/sync.go create mode 100644 internal/sync/sync_test.go diff --git a/internal/backup/backup.go b/internal/backup/backup.go new file mode 100644 index 000000000..a9655d677 --- /dev/null +++ b/internal/backup/backup.go @@ -0,0 +1,194 @@ +// Package backup ports src/commands/backup-db.ts — the `vip backup db` +// runner: trigger a database backup unless one is already running, poll +// the db_backup job until its in-progress lock clears, and verify the +// terminal status. +package backup + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/fatih/color" + "github.com/vektah/gqlparser/v2/gqlerror" + + "github.com/Automattic/vip/internal/poll" + "github.com/Automattic/vip/internal/tui" +) + +// DefaultPollInterval — DB_BACKUP_PROGRESS_POLL_INTERVAL (backup-db.ts:18). +const DefaultPollInterval = time.Second + +// DefaultPollTimeout is the ceiling backup-db.ts:198 inherits by calling +// pollUntil without a timeout: 6 hours (src/lib/utils.ts:18). +const DefaultPollTimeout = poll.DefaultTimeout + +// Step IDs (backup-db.ts:91). +const ( + StepPrepare = "prepare" + StepGenerate = "generate" +) + +// Job flattens the db_backup job fields the runner consumes +// (backup-db.ts:129-143). +type Job struct { + InProgressLock bool + Status string // progress.status + CompletedAt string + BackupName string // metadata[name=backupName].value; "Unknown" fallback is the caller's concern +} + +// Fetch retrieves the current db_backup job (nil when none exists). +type Fetch func(ctx context.Context) (*Job, error) + +// Create fires the TriggerDatabaseBackup mutation. +type Create func(ctx context.Context) error + +// RunOpts configures Run. Tracker must carry the prepare/generate steps. +type RunOpts struct { + Fetch Fetch + Create Create + Tracker *tui.ProgressTracker + Interval time.Duration + // Timeout caps the generate-phase poll. Zero means DefaultPollTimeout. + Timeout time.Duration + // Log mirrors BackupDBCommand.log (backup-db.ts:108); nil = silent. + Log func(msg string) + // FinalizeProgress flushes the completed tracker before the terminal + // success message is logged, matching BackupDBCommand.stopProgressTracker. + FinalizeProgress func() +} + +// Run ports BackupDBCommand.run (backup-db.ts:145). +func Run(ctx context.Context, opts RunOpts) error { + interval := opts.Interval + if interval == 0 { + interval = DefaultPollInterval + } + timeout := opts.Timeout + if timeout == 0 { + timeout = DefaultPollTimeout + } + logf := opts.Log + if logf == nil { + logf = func(string) {} + } + + job, err := opts.Fetch(ctx) + if err != nil { + return fmt.Errorf("Couldn't create a new database backup job: %s", err.Error()) + } + + if job != nil && job.InProgressLock { + logf("Database backup already in progress...") + } else { + logf("Generating a new database backup...") + _ = opts.Tracker.StepRunning(StepPrepare) + if err := opts.Create(ctx); err != nil { + _ = opts.Tracker.StepFailed(StepPrepare) + if retryAfter, ok := RateLimitInfo(err); ok { + // backup-db.ts:172-181. Node's template literal ends with + // a stray tab before the closing backtick; normalized to a + // plain newline here. + return fmt.Errorf("A new database backup was not generated because a recently generated backup already exists.\nIf you would like to run the same command, you can retry in %s\nAlternatively, you can export the latest existing database backup by running: %s, right away.\nLearn more about limitations around generating database backups: https://docs.wpvip.com/databases/backups/limitations/\n", + FormatDuration(time.Now(), retryAfter), + color.GreenString("vip @app.env export sql")) + } + return fmt.Errorf("Couldn't create a new database backup job: %s", err.Error()) + } + } + + _ = opts.Tracker.StepSuccess(StepPrepare) // auto-promotes generate to running + + // pollUntil(loadBackupJob, 1s, isDone) — isDone = !job.inProgressLock + // (backup-db.ts:115,198). Node passes no timeout, so this runs under + // pollUntil's 6h ceiling; PollingTimeoutError falls into the same catch + // as a fetch failure and becomes "Failed to create new database backup: + // Polling timed out" (backup-db.ts:203-212). + if _, err := poll.Until(ctx, opts.Fetch, interval, + func(j *Job) bool { return j == nil || !j.InProgressLock }, timeout); err != nil { + _ = opts.Tracker.StepFailed(StepGenerate) + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + return fmt.Errorf("Failed to create new database backup: %s", err.Error()) + } + + _ = opts.Tracker.StepSuccess(StepGenerate) + + // Final verification re-fetch (backup-db.ts:218-224). + job, err = opts.Fetch(ctx) + if err != nil || job == nil || job.Status != "success" { + return errors.New("Failed to create a new database backup") + } + if opts.FinalizeProgress != nil { + opts.FinalizeProgress() + } + logf("New database backup created") + return nil +} + +// FormatDuration ports format.ts:242 formatDuration: " day(s) +// hour(s) minute(s) second(s)", omitting zero units, trailing +// space trimmed; "0 second" when under one second. +func FormatDuration(from, to time.Time) string { + duration := to.Sub(from) + if duration < time.Second { + return "0 second" + } + days := int(duration / (24 * time.Hour)) + hours := int(duration % (24 * time.Hour) / time.Hour) + minutes := int(duration % time.Hour / time.Minute) + seconds := int(duration % time.Minute / time.Second) + + var b strings.Builder + plural := func(n int, unit string) { + if n > 0 { + fmt.Fprintf(&b, "%d %s", n, unit) + if n > 1 { + b.WriteString("s") + } + b.WriteString(" ") + } + } + plural(days, "day") + plural(hours, "hour") + plural(minutes, "minute") + plural(seconds, "second") + return strings.TrimRight(b.String(), " ") +} + +// RateLimitInfo extracts the 429 rate-limit extensions from a genqlient +// error (gqlerror.List; backup-db.ts:162-166 reads +// extensions.errorHttpCode + extensions.retryAfter). ok=false when the +// error isn't a parseable rate limit. +func RateLimitInfo(err error) (retryAfter time.Time, ok bool) { + var list gqlerror.List + var single *gqlerror.Error + var ext map[string]interface{} + switch { + case errors.As(err, &list) && len(list) > 0: + ext = list[0].Extensions + case errors.As(err, &single): + ext = single.Extensions + default: + return time.Time{}, false + } + code, isFloat := ext["errorHttpCode"].(float64) + codeInt, isInt := ext["errorHttpCode"].(int) + if (!isFloat || int(code) != 429) && (!isInt || codeInt != 429) { + return time.Time{}, false + } + raw, _ := ext["retryAfter"].(string) + if raw == "" { + return time.Time{}, false + } + for _, layout := range []string{time.RFC3339, time.RFC1123, "2006-01-02 15:04:05"} { + if t, perr := time.Parse(layout, raw); perr == nil { + return t, true + } + } + return time.Time{}, false +} diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go new file mode 100644 index 000000000..eda23ac48 --- /dev/null +++ b/internal/backup/backup_test.go @@ -0,0 +1,228 @@ +package backup + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/vektah/gqlparser/v2/gqlerror" + + "github.com/Automattic/vip/internal/tui" +) + +func tracker() *tui.ProgressTracker { + return tui.NewProgressTracker([]tui.ProgressStep{ + {ID: StepPrepare, Name: "Preparing for backup generation"}, + {ID: StepGenerate, Name: "Generating backup"}, + }) +} + +func scriptedFetch(jobs []*Job, errs []error) Fetch { + i := 0 + return func(ctx context.Context) (*Job, error) { + idx := i + if i < len(jobs)-1 { + i++ + } + var err error + if idx < len(errs) { + err = errs[idx] + } + return jobs[idx], err + } +} + +func TestFormatDuration(t *testing.T) { + now := time.Now() + cases := []struct { + d time.Duration + want string + }{ + {500 * time.Millisecond, "0 second"}, + {time.Second, "1 second"}, + {65 * time.Second, "1 minute 5 seconds"}, + {49 * time.Hour, "2 days 1 hour"}, + {time.Hour + time.Minute + time.Second, "1 hour 1 minute 1 second"}, + } + for _, tc := range cases { + if got := FormatDuration(now, now.Add(tc.d)); got != tc.want { + t.Errorf("FormatDuration(+%v) = %q, want %q", tc.d, got, tc.want) + } + } +} + +func TestRunHappyPath(t *testing.T) { + var logs []string + created := 0 + err := Run(context.Background(), RunOpts{ + Fetch: scriptedFetch([]*Job{ + nil, // initial load: no job + {InProgressLock: true}, + {InProgressLock: true}, + {InProgressLock: false, Status: "success", BackupName: "b1"}, + }, nil), + Create: func(ctx context.Context) error { created++; return nil }, + Tracker: tracker(), + Interval: time.Millisecond, + Log: func(m string) { logs = append(logs, m) }, + }) + if err != nil { + t.Fatal(err) + } + if created != 1 { + t.Errorf("Create called %d times", created) + } + joined := strings.Join(logs, "|") + if !strings.Contains(joined, "Generating a new database backup...") || + !strings.Contains(joined, "New database backup created") { + t.Errorf("logs = %v", logs) + } +} + +func TestRunAlreadyInProgress(t *testing.T) { + var logs []string + created := 0 + err := Run(context.Background(), RunOpts{ + Fetch: scriptedFetch([]*Job{ + {InProgressLock: true}, + {InProgressLock: false, Status: "success"}, + }, nil), + Create: func(ctx context.Context) error { created++; return nil }, + Tracker: tracker(), + Interval: time.Millisecond, + Log: func(m string) { logs = append(logs, m) }, + }) + if err != nil { + t.Fatal(err) + } + if created != 0 { + t.Error("Create must not fire when a backup is already running (backup-db.ts:150)") + } + if !strings.Contains(strings.Join(logs, "|"), "Database backup already in progress...") { + t.Errorf("logs = %v", logs) + } +} + +func TestRunFinalStatusNotSuccess(t *testing.T) { + err := Run(context.Background(), RunOpts{ + Fetch: scriptedFetch([]*Job{ + nil, + {InProgressLock: false, Status: "failed"}, + }, nil), + Create: func(ctx context.Context) error { return nil }, + Tracker: tracker(), + Interval: time.Millisecond, + }) + if err == nil || err.Error() != "Failed to create a new database backup" { + t.Errorf("err = %v", err) + } +} + +func TestRunCreateFails(t *testing.T) { + err := Run(context.Background(), RunOpts{ + Fetch: scriptedFetch([]*Job{nil}, nil), + Create: func(ctx context.Context) error { return errors.New("boom") }, + Tracker: tracker(), + Interval: time.Millisecond, + }) + if err == nil || err.Error() != "Couldn't create a new database backup job: boom" { + t.Errorf("err = %v", err) + } +} + +func TestRunCreateRateLimited(t *testing.T) { + retryAt := time.Now().Add(90 * time.Minute).Format(time.RFC3339) + rlErr := gqlerror.List{&gqlerror.Error{ + Message: "rate limited", + Extensions: map[string]interface{}{ + "errorHttpCode": float64(429), + "retryAfter": retryAt, + }, + }} + err := Run(context.Background(), RunOpts{ + Fetch: scriptedFetch([]*Job{nil}, nil), + Create: func(ctx context.Context) error { return rlErr }, + Tracker: tracker(), + Interval: time.Millisecond, + }) + if err == nil || + !strings.Contains(err.Error(), "A new database backup was not generated because a recently generated backup already exists.") || + !strings.Contains(err.Error(), "vip @app.env export sql") || + !strings.Contains(err.Error(), "https://docs.wpvip.com/databases/backups/limitations/") { + t.Errorf("err = %v", err) + } + if !strings.Contains(err.Error(), "hour") && !strings.Contains(err.Error(), "minute") { + t.Errorf("rate-limit message missing duration: %v", err) + } +} + +// TestDefaultPollTimeoutIsNodesSixHourCeiling pins the ceiling `vip backup db` +// inherits from Node: backup-db.ts:198 calls pollUntil with no explicit +// timeout, so it gets the 6h default from utils.ts:18. +func TestDefaultPollTimeoutIsNodesSixHourCeiling(t *testing.T) { + if DefaultPollTimeout != 6*time.Hour { + t.Errorf("DefaultPollTimeout = %v, want 6h", DefaultPollTimeout) + } +} + +// TestRunStopsWhenBackupNeverCompletes is the regression test for the +// unbounded generate-phase poll loop: a job whose inProgressLock never +// clears used to spin forever (in CI: a wedged run instead of a failure). +// Node's pollUntil gives up at the ceiling and the surrounding catch turns +// PollingTimeoutError into `Failed to create new database backup: Polling +// timed out` (backup-db.ts:203-212). +func TestRunStopsWhenBackupNeverCompletes(t *testing.T) { + fetches := 0 + done := make(chan error, 1) + go func() { + done <- Run(context.Background(), RunOpts{ + Fetch: func(ctx context.Context) (*Job, error) { + fetches++ + return &Job{InProgressLock: true}, nil + }, + Create: func(ctx context.Context) error { return nil }, + Tracker: tracker(), + Interval: time.Millisecond, + Timeout: 50 * time.Millisecond, + }) + }() + + select { + case err := <-done: + if err == nil || err.Error() != "Failed to create new database backup: Polling timed out" { + t.Errorf("err = %v, want %q", err, + "Failed to create new database backup: Polling timed out") + } + if fetches < 2 { + t.Errorf("fetches = %d, want the loop to have actually polled", fetches) + } + case <-time.After(5 * time.Second): + t.Fatal("Run never returned: the generate-phase poll loop is unbounded") + } +} + +func TestRateLimitInfo(t *testing.T) { + retryAt := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + list := gqlerror.List{&gqlerror.Error{ + Message: "x", + Extensions: map[string]interface{}{ + "errorHttpCode": float64(429), + "retryAfter": retryAt.Format(time.RFC3339), + }, + }} + got, ok := RateLimitInfo(list) + if !ok || !got.Equal(retryAt) { + t.Errorf("got %v ok=%v", got, ok) + } + + if _, ok := RateLimitInfo(errors.New("plain")); ok { + t.Error("plain error must not parse as rate limit") + } + if _, ok := RateLimitInfo(gqlerror.List{&gqlerror.Error{ + Message: "x", Extensions: map[string]interface{}{"errorHttpCode": float64(500)}, + }}); ok { + t.Error("non-429 must not parse as rate limit") + } +} diff --git a/internal/customdeploy/archive.go b/internal/customdeploy/archive.go new file mode 100644 index 000000000..92ea80439 --- /dev/null +++ b/internal/customdeploy/archive.go @@ -0,0 +1,205 @@ +package customdeploy + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "errors" + "fmt" + "io" + "os" + "path" + "regexp" + "strings" +) + +// Error messages — validations/custom-deploy.ts:14. +const ( + errMissingThemes = "Missing `themes` directory from root folder." + errSymlink = "Symlink detected: " + errSingleRootDir = "The compressed file must contain a single root directory." +) + +const macosxDir = "__MACOSX" + +// symlinkIgnoreRE — validations/custom-deploy.ts:22. +var symlinkIgnoreRE = regexp.MustCompile(`/node_modules/[^/]+/\.bin/`) + +// Per-entry name patterns — validations/custom-deploy.ts:67. +var ( + invalidDirCharsRE = regexp.MustCompile(`[!:*?"<>|']|^\.\..*$`) + invalidFileCharsRE = regexp.MustCompile(`[!/:*?"<>|']|^\.\..*$`) +) + +// validateName ports validateName (validations/custom-deploy.ts:62). +func validateName(name string, isDirectory bool) error { + if strings.HasPrefix(name, "._") { + return nil + } + re := invalidFileCharsRE + chars := `[!/:*?"<>|'/^..]+` + if isDirectory { + re = invalidDirCharsRE + chars = `[!:*?"<>|'/^..]+` + } + if re.MatchString(name) { + return fmt.Errorf("Filename %s contains disallowed characters: %s", name, chars) + } + return nil +} + +// ValidateZipFile ports validateZipFile (validations/custom-deploy.ts:143). +func ValidateZipFile(filePath string) error { + zr, err := zip.OpenReader(filePath) + if err != nil { + return fmt.Errorf("Error reading file: %s", err.Error()) + } + defer zr.Close() + + var rootDirs []string + for _, f := range zr.File { + name := f.Name + if !strings.HasSuffix(name, "/") || strings.HasPrefix(name, macosxDir) { + continue + } + if strings.Count(name, "/") == 1 { + rootDirs = append(rootDirs, name) + } + } + if len(rootDirs) != 1 { + return errors.New(errSingleRootDir) + } + rootFolder := rootDirs[0] + + // themes/ under the root (validations/custom-deploy.ts:124). + hasThemes := false + requiredPrefix := path.Join(rootFolder, "themes") + "/" + for _, f := range zr.File { + name := strings.ReplaceAll(f.Name, `\`, "/") + if strings.HasSuffix(f.Name, "/") && strings.HasPrefix(name, requiredPrefix) { + hasThemes = true + break + } + } + if !hasThemes { + return errors.New(errMissingThemes) + } + + for _, f := range zr.File { + if strings.HasPrefix(f.Name, macosxDir) { + continue + } + isDir := strings.HasSuffix(f.Name, "/") + name := f.Name + if !isDir { + name = path.Base(f.Name) + } + if err := validateName(name, isDir); err != nil { + return err + } + // Symlink detection: Go's zip reader surfaces the Unix mode bits + // from the external attributes (the ts:97 case). The DOS-attr + // variant (ts:92) is not reachable through archive/zip — noted as + // an intentional gap. + if symlinkIgnoreRE.MatchString(f.Name) { + continue + } + if f.Mode()&os.ModeSymlink != 0 { + return errors.New(errSymlink + f.Name) + } + } + return nil +} + +// ValidateTarFile ports validateTarFile (validations/custom-deploy.ts:220). +// Handles gzipped (.tar.gz/.tgz) and plain tar input. +func ValidateTarFile(filePath string) error { + f, err := os.Open(filePath) // #nosec G304 -- user-supplied CLI path + if err != nil { + return err + } + defer f.Close() + + var r io.Reader = f + magic := make([]byte, 2) + if _, err := io.ReadFull(f, magic); err == nil && magic[0] == 0x1f && magic[1] == 0x8b { + if _, err := f.Seek(0, io.SeekStart); err != nil { + return err + } + zr, err := gzip.NewReader(f) + if err != nil { + return err + } + defer zr.Close() + r = zr + } else { + if _, err := f.Seek(0, io.SeekStart); err != nil { + return err + } + } + + tr := tar.NewReader(r) + rootFolder := "" + type tarEntry struct { + path string + isDir bool + } + var entries []tarEntry + + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + name := hdr.Name + if strings.HasPrefix(name, macosxDir) { + continue + } + var isDir, isSymlink bool + switch hdr.Typeflag { + case tar.TypeDir: + isDir = true + case tar.TypeReg: + case tar.TypeSymlink, tar.TypeLink: + isSymlink = hdr.Typeflag == tar.TypeSymlink + if !isSymlink { + continue + } + default: + continue + } + + isRootFolder := isDir && strings.HasSuffix(name, "/") && strings.Count(name, "/") == 1 + if isRootFolder { + if rootFolder == "" { + rootFolder = name + } else if rootFolder != name { + return errors.New(errSingleRootDir) + } + } + + // validateTarEntry (ts:191): symlink check first, then name. + if isSymlink && !symlinkIgnoreRE.MatchString(name) { + return errors.New(errSymlink + name) + } + if err := validateName(path.Base(strings.TrimSuffix(name, "/")), isDir); err != nil { + return err + } + entries = append(entries, tarEntry{path: name, isDir: isDir}) + } + + if rootFolder == "" { + return errors.New(errSingleRootDir) + } + + themesPath := path.Join(rootFolder, "themes") + "/" + for _, e := range entries { + if e.isDir && e.path == themesPath { + return nil + } + } + return errors.New(errMissingThemes) +} diff --git a/internal/customdeploy/archive_test.go b/internal/customdeploy/archive_test.go new file mode 100644 index 000000000..d22308b26 --- /dev/null +++ b/internal/customdeploy/archive_test.go @@ -0,0 +1,174 @@ +package customdeploy + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "os" + "path/filepath" + "strings" + "testing" +) + +// buildZip writes a zip with the given entries; names ending in "/" are +// directories; symlinkTargets maps entry name -> target. +func buildZip(t *testing.T, entries []string, symlinks map[string]string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "app.zip") + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(f) + for _, name := range entries { + hdr := &zip.FileHeader{Name: name} + if strings.HasSuffix(name, "/") { + hdr.SetMode(os.ModeDir | 0o755) + } else { + hdr.SetMode(0o644) + } + w, err := zw.CreateHeader(hdr) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(name, "/") { + _, _ = w.Write([]byte("x")) + } + } + for name, target := range symlinks { + hdr := &zip.FileHeader{Name: name} + hdr.SetMode(os.ModeSymlink | 0o777) + w, err := zw.CreateHeader(hdr) + if err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(target)) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return p +} + +// buildTarGz writes a .tar.gz with dirs (trailing /), files, and symlinks. +func buildTarGz(t *testing.T, dirs, files []string, symlinks map[string]string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "app.tar.gz") + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + zw := gzip.NewWriter(f) + tw := tar.NewWriter(zw) + for _, d := range dirs { + if err := tw.WriteHeader(&tar.Header{Name: d, Typeflag: tar.TypeDir, Mode: 0o755}); err != nil { + t.Fatal(err) + } + } + for _, fl := range files { + if err := tw.WriteHeader(&tar.Header{Name: fl, Typeflag: tar.TypeReg, Mode: 0o644, Size: 1}); err != nil { + t.Fatal(err) + } + _, _ = tw.Write([]byte("x")) + } + for name, target := range symlinks { + if err := tw.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeSymlink, Linkname: target, Mode: 0o777}); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return p +} + +func TestValidateZipFileClean(t *testing.T) { + p := buildZip(t, []string{ + "app/", "app/themes/", "app/themes/style.css", "app/plugins/", "app/plugins/x.php", + "__MACOSX/", "__MACOSX/junk!|.txt", + }, nil) + if err := ValidateZipFile(p); err != nil { + t.Errorf("err = %v", err) + } +} + +func TestValidateZipFileTwoRoots(t *testing.T) { + p := buildZip(t, []string{"a/", "a/themes/", "b/", "b/x.txt"}, nil) + if err := ValidateZipFile(p); err == nil || err.Error() != errSingleRootDir { + t.Errorf("err = %v", err) + } +} + +func TestValidateZipFileMissingThemes(t *testing.T) { + p := buildZip(t, []string{"app/", "app/plugins/"}, nil) + if err := ValidateZipFile(p); err == nil || err.Error() != errMissingThemes { + t.Errorf("err = %v", err) + } +} + +func TestValidateZipFileSymlink(t *testing.T) { + p := buildZip(t, []string{"app/", "app/themes/"}, + map[string]string{"app/evil-link": "/etc/passwd"}) + if err := ValidateZipFile(p); err == nil || !strings.Contains(err.Error(), "Symlink detected: app/evil-link") { + t.Errorf("err = %v", err) + } + // node_modules/.bin symlinks are exempt (validations/custom-deploy.ts:22). + p = buildZip(t, []string{"app/", "app/themes/"}, + map[string]string{"app/node_modules/pkg/.bin/tool": "../lib/tool.js"}) + if err := ValidateZipFile(p); err != nil { + t.Errorf("exempt symlink rejected: %v", err) + } +} + +func TestValidateZipFileBadChars(t *testing.T) { + p := buildZip(t, []string{"app/", "app/themes/", "app/bad?.txt"}, nil) + if err := ValidateZipFile(p); err == nil || !strings.Contains(err.Error(), "contains disallowed characters") { + t.Errorf("err = %v", err) + } +} + +func TestValidateTarFileClean(t *testing.T) { + p := buildTarGz(t, + []string{"app/", "app/themes/"}, + []string{"app/themes/style.css"}, + nil) + if err := ValidateTarFile(p); err != nil { + t.Errorf("err = %v", err) + } +} + +func TestValidateTarFileMissingThemes(t *testing.T) { + p := buildTarGz(t, []string{"app/"}, []string{"app/x.php"}, nil) + if err := ValidateTarFile(p); err == nil || err.Error() != errMissingThemes { + t.Errorf("err = %v", err) + } +} + +func TestValidateTarFileTwoRoots(t *testing.T) { + p := buildTarGz(t, []string{"a/", "a/themes/", "b/"}, nil, nil) + if err := ValidateTarFile(p); err == nil || err.Error() != errSingleRootDir { + t.Errorf("err = %v", err) + } +} + +func TestValidateTarFileSymlink(t *testing.T) { + p := buildTarGz(t, []string{"app/", "app/themes/"}, nil, + map[string]string{"app/evil": "/etc/passwd"}) + if err := ValidateTarFile(p); err == nil || !strings.Contains(err.Error(), "Symlink detected: app/evil") { + t.Errorf("err = %v", err) + } + p = buildTarGz(t, []string{"app/", "app/themes/"}, nil, + map[string]string{"app/node_modules/pkg/.bin/tool": "x"}) + if err := ValidateTarFile(p); err != nil { + t.Errorf("exempt symlink rejected: %v", err) + } +} diff --git a/internal/customdeploy/customdeploy.go b/internal/customdeploy/customdeploy.go new file mode 100644 index 000000000..31b4e2b56 --- /dev/null +++ b/internal/customdeploy/customdeploy.go @@ -0,0 +1,91 @@ +// Package customdeploy ports src/lib/custom-deploy/custom-deploy.ts and +// src/lib/validations/custom-deploy.ts — the gates and archive checks +// behind `vip app deploy` (+ `validate`). +package customdeploy + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/Automattic/vip/internal/upload" +) + +// DeployMaxFileSize — DEPLOY_MAX_FILE_SIZE = 4 GiB (custom-deploy.ts:11). +const DeployMaxFileSize = int64(4) * 1024 * 1024 * 1024 + +// DeployInfo mirrors CustomDeployInfo (custom-deploy.ts:14). +type DeployInfo struct { + AppID int64 + EnvID int64 + EnvType string + EnvUniqueLabel string + PrimaryDomainName string + Launched bool +} + +// validFilenameRE — validations/custom-deploy.ts:49 (same charset as +// import sql, different message). +var validFilenameRE = regexp.MustCompile(`(?i)^[a-z0-9\-_.]+$`) + +// ValidateDeployFilename ports validateFilename (validations/custom-deploy.ts:48). +func ValidateDeployFilename(filename string) error { + if !validFilenameRE.MatchString(filename) { + return fmt.Errorf("Filename %s contains disallowed characters: [0-9,a-z,A-Z,-,_,.]", filename) + } + return nil +} + +// ValidateDeployFileExt ports validateDeployFileExt +// (validations/custom-deploy.ts:31): .zip, .tar.gz, or .tgz. +func ValidateDeployFileExt(filename string) error { + ext := strings.ToLower(filepath.Ext(filename)) + if ext == ".gz" && strings.ToLower(filepath.Ext(strings.TrimSuffix(filename, filepath.Ext(filename)))) == ".tar" { + ext = ".tar.gz" + } + if ext != ".zip" && ext != ".tar.gz" && ext != ".tgz" { + return errors.New("Invalid file extension. Please provide a .zip, .tar.gz, or a .tgz file.") + } + return nil +} + +// ValidateFile ports validateFile (custom-deploy.ts:74): the gate +// sequence ahead of upload. maxSize is injectable for tests; 0 uses the +// 4 GiB production limit. +func ValidateFile(meta upload.FileMeta, maxSize int64) error { + if maxSize == 0 { + maxSize = DeployMaxFileSize + } + + fi, statErr := os.Stat(meta.FileName) + if statErr != nil { + return fmt.Errorf("Unable to access file %s", meta.FileName) + } + if !meta.IsCompressed { + return fmt.Errorf("Please compress file %s before uploading.", meta.FileName) + } + if err := ValidateDeployFilename(meta.BaseName); err != nil { + return err + } + if err := ValidateDeployFileExt(meta.FileName); err != nil { + return err + } + if f, err := os.Open(meta.FileName); err != nil { // #nosec G304 -- checkFileAccess parity + return fmt.Errorf("File '%s' does not exist or is not readable.", meta.FileName) + } else { + f.Close() + } + if fi.IsDir() { + return fmt.Errorf("Path '%s' is not a file.", meta.FileName) + } + if fi.Size() == 0 { + return fmt.Errorf("File '%s' is empty.", meta.FileName) + } + if fi.Size() > maxSize { + return fmt.Errorf("The deploy file size (%d bytes) exceeds the limit (%d bytes).", fi.Size(), maxSize) + } + return nil +} diff --git a/internal/customdeploy/customdeploy_test.go b/internal/customdeploy/customdeploy_test.go new file mode 100644 index 000000000..9eb81a645 --- /dev/null +++ b/internal/customdeploy/customdeploy_test.go @@ -0,0 +1,82 @@ +package customdeploy + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Automattic/vip/internal/upload" +) + +func metaFor(t *testing.T, name string, content []byte) upload.FileMeta { + t.Helper() + p := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(p, content, 0o600); err != nil { + t.Fatal(err) + } + meta, err := upload.GetFileMeta(p) + if err != nil { + t.Fatal(err) + } + return meta +} + +// gzMagic makes content sniff as gzip so IsCompressed is true. +var gzMagic = []byte{0x1f, 0x8b, 0x08, 0x00, 0x01, 0x02, 0x03} + +func TestValidateDeployFileExt(t *testing.T) { + for name, ok := range map[string]bool{ + "app.zip": true, "app.tar.gz": true, "app.tgz": true, "APP.TGZ": true, + "app.sql": false, "app.gz": false, "app.tar": false, + } { + err := ValidateDeployFileExt(name) + if ok && err != nil { + t.Errorf("%s: unexpected err %v", name, err) + } + if !ok && (err == nil || !strings.Contains(err.Error(), "Invalid file extension. Please provide a .zip, .tar.gz, or a .tgz file.")) { + t.Errorf("%s: err = %v", name, err) + } + } +} + +func TestValidateDeployFilename(t *testing.T) { + if err := ValidateDeployFilename("release-1.2.3.tgz"); err != nil { + t.Errorf("err = %v", err) + } + err := ValidateDeployFilename("bad name!.zip") + if err == nil || !strings.Contains(err.Error(), "Filename bad name!.zip contains disallowed characters: [0-9,a-z,A-Z,-,_,.]") { + t.Errorf("err = %v", err) + } +} + +func TestValidateFileGates(t *testing.T) { + uncompressed := metaFor(t, "app.tgz", []byte("plain text, not gzip")) + if err := ValidateFile(uncompressed, 0); err == nil || + !strings.Contains(err.Error(), "Please compress file") { + t.Errorf("err = %v", err) + } + + good := metaFor(t, "app.tgz", gzMagic) + if err := ValidateFile(good, 0); err != nil { + t.Errorf("err = %v", err) + } + + tooBig := metaFor(t, "app.tgz", append(gzMagic, make([]byte, 100)...)) + if err := ValidateFile(tooBig, 10); err == nil || + !strings.Contains(err.Error(), "exceeds the limit (10 bytes).") { + t.Errorf("err = %v", err) + } + + missing := upload.FileMeta{FileName: filepath.Join(t.TempDir(), "nope.tgz"), BaseName: "nope.tgz", IsCompressed: true} + if err := ValidateFile(missing, 0); err == nil || + !strings.Contains(err.Error(), "Unable to access file") { + t.Errorf("err = %v", err) + } + + badExt := metaFor(t, "app.gz", gzMagic) + if err := ValidateFile(badExt, 0); err == nil || + !strings.Contains(err.Error(), "Invalid file extension.") { + t.Errorf("err = %v", err) + } +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go new file mode 100644 index 000000000..afedf379d --- /dev/null +++ b/internal/sync/sync.go @@ -0,0 +1,246 @@ +// Package sync wraps the SyncEnvironment + SyncProgress genqlient +// operations behind a Go-friendly surface. +// +// The package intentionally collides with stdlib `sync`; callers should +// import it under an alias (e.g. `syncpkg`). +// +// Node parity references: src/bin/vip-sync.js. Notable schema facts +// discovered while porting: +// +// - AppEnvironmentSyncInput uses Id (not appId) for the application ID. +// - AppEnvironmentSyncProgress.sync is Int (the job ID), not String. +// - AppEnvironmentSyncStep has three fields — Name, Status, Step — +// where Step is the stable identifier (the plan only listed two). +// +// The "Site is already syncing" GraphQL error is treated specially: +// Start returns an AlreadySyncingError sentinel so the handler can +// proceed to polling without surfacing the message as a fatal error +// (mirrors Node's CombinedGraphQLErrors check). +package sync + +import ( + "context" + "strings" + "time" + + "github.com/Khan/genqlient/graphql" + + "github.com/Automattic/vip/internal/gql" +) + +// AlreadySyncingErrMsg is the exact server error string that signals an +// in-progress sync. Compared as a substring (not equality) because the +// transport may wrap the message with location metadata; Node's check +// uses exact equality against err.message, but matching as substring is +// strictly more permissive and keeps us robust against minor wrapping. +const AlreadySyncingErrMsg = "Site is already syncing" + +// AlreadySyncingError is returned by Start when the server rejects the +// mutation because a sync is already underway. Callers detect this with +// errors.As / errors.Is to switch to the "join the existing run" path. +type AlreadySyncingError struct{} + +func (AlreadySyncingError) Error() string { return AlreadySyncingErrMsg } + +// Status constants — string values that come back from the API. These +// are the only states currently observed in production; "unknown" is a +// client-side marker for per-step status values not in this list. +const ( + StatusRunning = "running" + StatusSuccess = "success" + StatusFailed = "failed" + StatusPending = "pending" +) + +// Step is the flat per-step view of an in-flight sync. +type Step struct { + Name string + Status string + Step string +} + +// Progress is the overall sync state. Sync is the job ID (Int in the +// schema, despite the plan calling it String). +type Progress struct { + Status string + Sync int64 + Steps []Step +} + +// IsTerminal reports whether the status string is a terminal state +// (success or failed). Running and pending are not terminal. +func IsTerminal(status string) bool { + return status == StatusSuccess || status == StatusFailed +} + +// Start triggers a sync of the production env into the target env. +// On "Site is already syncing", returns AlreadySyncingError so the +// caller can fall through to polling. Other GraphQL errors are +// returned verbatim. +// +// The provided ctx MUST opt out of the error middleware via +// gql.WithAllowGQLErrors so the middleware does not Exit(1) on the +// "already syncing" response before this function sees it. +func Start(ctx context.Context, c graphql.Client, appID, envID int64) error { + id := appID + envIDLocal := envID + input := &gql.AppEnvironmentSyncInput{ + Id: id, + EnvironmentId: envIDLocal, + } + _, err := gql.SyncEnvironment(ctx, c, input) + if err == nil { + return nil + } + if isAlreadySyncing(err) { + return AlreadySyncingError{} + } + return err +} + +// isAlreadySyncing returns true if the err chain contains the +// "Site is already syncing" message. genqlient surfaces server errors +// as a gqlerror.List whose Error() concatenates each .Message; we +// substring-match instead of poking at the list directly to keep this +// resilient to genqlient internals. +func isAlreadySyncing(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), AlreadySyncingErrMsg) +} + +// Status returns the current sync progress for (appID, envID). Returns +// (nil, nil) when the server response shape is present but lacks a +// syncProgress block (e.g. immediately after Start fires, before the +// background job kicks in). +func Status(ctx context.Context, c graphql.Client, appID, envID int64) (*Progress, error) { + resp, err := gql.SyncProgress(ctx, c, appID, envID) + if err != nil { + return nil, err + } + if resp == nil || resp.App == nil { + return nil, nil + } + for _, e := range resp.App.Environments { + if e == nil { + continue + } + // Defensive: server-side query already filters by envID, but a future + // caller (or schema change) might surface multiple envs in the slice. + // Match explicitly so we never return a sibling env's progress. + // If the server omits id (nullable scalar), accept the first env — + // matching pre-filter behavior so test fixtures aren't forced to + // echo the id back. + if e.Id != nil && *e.Id != envID { + continue + } + if e.SyncProgress == nil { + return nil, nil + } + p := &Progress{} + if e.SyncProgress.Status != nil { + p.Status = *e.SyncProgress.Status + } + if e.SyncProgress.Sync != nil { + p.Sync = *e.SyncProgress.Sync + } + for _, s := range e.SyncProgress.Steps { + if s == nil { + continue + } + step := Step{} + if s.Name != nil { + step.Name = *s.Name + } + if s.Status != nil { + step.Status = *s.Status + } + if s.Step != nil { + step.Step = *s.Step + } + p.Steps = append(p.Steps, step) + } + return p, nil + } + return nil, nil +} + +// PollOpts configures the Poll loop. +type PollOpts struct { + // Interval between Status queries. Zero falls back to DefaultInterval. + Interval time.Duration + // OnTransition, if non-nil, is invoked exactly once per step on each + // observed status change (including the first time the step is seen). + // The argument is the step's NEW state. + OnTransition func(Step) + // OnError, if non-nil, is consulted on transient Status errors. Return + // true to keep polling (treat as transient), false to abort the loop + // with the error. + OnError func(error) bool +} + +// DefaultInterval is the production poll cadence. Tests can override via +// PollOpts.Interval (or, at the handler level, VIP_SYNC_INTERVAL_MS). +const DefaultInterval = 5 * time.Second + +// Poll calls Status on a tick and returns when the sync reaches a +// terminal state (success or failed), the context is cancelled, or an +// error is judged fatal by OnError. The first Status call happens +// immediately (no leading sleep), so callers see step transitions +// without waiting one full Interval first. +// +// Footgun: OnError = func(error) bool { return true } + a context with +// no deadline = infinite silent retry loop. The handler in vip sync uses +// that pairing intentionally for Node parity (Node's setInterval ignores +// poll errors), but it relies on the user being there to hit Ctrl-C. +// Callers without an interactive user MUST pass a context with a +// timeout, OR set OnError to a function that returns false after N +// consecutive failures. +func Poll(ctx context.Context, c graphql.Client, appID, envID int64, opts PollOpts) (*Progress, error) { + interval := opts.Interval + if interval <= 0 { + interval = DefaultInterval + } + // Track the last-seen status per step (by Step identifier when + // available, else falling back to Name). Allows the loop to fire + // OnTransition only on actual change rather than once per tick. + seen := map[string]string{} + + keyOf := func(s Step) string { + if s.Step != "" { + return s.Step + } + return s.Name + } + + for { + p, err := Status(ctx, c, appID, envID) + if err != nil { + if opts.OnError != nil && opts.OnError(err) { + // Transient — sleep and retry. + } else { + return nil, err + } + } else if p != nil { + if opts.OnTransition != nil { + for _, s := range p.Steps { + k := keyOf(s) + if prev, ok := seen[k]; !ok || prev != s.Status { + seen[k] = s.Status + opts.OnTransition(s) + } + } + } + if IsTerminal(p.Status) { + return p, nil + } + } + + select { + case <-ctx.Done(): + return p, ctx.Err() + case <-time.After(interval): + } + } +} diff --git a/internal/sync/sync_test.go b/internal/sync/sync_test.go new file mode 100644 index 000000000..46d02b7a9 --- /dev/null +++ b/internal/sync/sync_test.go @@ -0,0 +1,253 @@ +package sync + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Khan/genqlient/graphql" +) + +// syncStub is a per-operation GraphQL fake. Each operation is keyed by +// the JSON request's operationName; the value is either a static body +// or a function that returns the body for the i-th hit (0-indexed). +type syncStub struct { + mu sync.Mutex + bodies map[string]func(int) string + hits map[string]int + defaultBody string +} + +func newStub() *syncStub { + return &syncStub{ + bodies: map[string]func(int) string{}, + hits: map[string]int{}, + defaultBody: `{"data":null}`, + } +} + +func (s *syncStub) setStatic(op, body string) { + s.bodies[op] = func(int) string { return body } +} + +func (s *syncStub) setSeq(op string, bodies ...string) { + s.bodies[op] = func(i int) string { + if i >= len(bodies) { + return bodies[len(bodies)-1] + } + return bodies[i] + } +} + +func (s *syncStub) start(t *testing.T) (*httptest.Server, graphql.Client) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + op := extractOp(string(buf)) + + s.mu.Lock() + fn := s.bodies[op] + i := s.hits[op] + s.hits[op] = i + 1 + s.mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + if fn == nil { + _, _ = w.Write([]byte(s.defaultBody)) + return + } + _, _ = w.Write([]byte(fn(i))) + })) + c := graphql.NewClient(srv.URL+"/graphql", srv.Client()) + return srv, c +} + +// extractOp finds the operationName value in a JSON GraphQL request +// body without parsing the whole document. Substring-search is enough +// for tests. +func extractOp(body string) string { + const key = `"operationName":"` + i := strings.Index(body, key) + if i < 0 { + return "" + } + rest := body[i+len(key):] + j := strings.Index(rest, `"`) + if j < 0 { + return "" + } + return rest[:j] +} + +func TestStartHappyPath(t *testing.T) { + stub := newStub() + stub.setStatic("SyncEnvironment", + `{"data":{"syncEnvironment":{"environment":{"id":7}}}}`) + srv, c := stub.start(t) + defer srv.Close() + + if err := Start(context.Background(), c, 42, 7); err != nil { + t.Fatalf("Start: %v", err) + } +} + +func TestStartAlreadySyncing(t *testing.T) { + stub := newStub() + stub.setStatic("SyncEnvironment", + `{"data":null,"errors":[{"message":"Site is already syncing"}]}`) + srv, c := stub.start(t) + defer srv.Close() + + err := Start(context.Background(), c, 42, 7) + if err == nil { + t.Fatal("expected AlreadySyncingError, got nil") + } + var ase AlreadySyncingError + if !errors.As(err, &ase) { + t.Fatalf("err = %v (%T), want AlreadySyncingError", err, err) + } +} + +func TestStartOtherErrorPassthrough(t *testing.T) { + stub := newStub() + stub.setStatic("SyncEnvironment", + `{"data":null,"errors":[{"message":"App not found"}]}`) + srv, c := stub.start(t) + defer srv.Close() + + err := Start(context.Background(), c, 42, 7) + if err == nil { + t.Fatal("expected error, got nil") + } + var ase AlreadySyncingError + if errors.As(err, &ase) { + t.Fatalf("expected non-AlreadySyncingError; got %v", err) + } + if !strings.Contains(err.Error(), "App not found") { + t.Fatalf("err = %v, want substring 'App not found'", err) + } +} + +func TestStatusReturnsProgress(t *testing.T) { + stub := newStub() + stub.setStatic("SyncProgress", `{"data":{"app":{"id":42,"environments":[ + {"id":7,"syncProgress":{"status":"running","sync":99,"steps":[ + {"name":"Backup","status":"success","step":"backup"}, + {"name":"Restore","status":"running","step":"restore"} + ]}} + ]}}}`) + srv, c := stub.start(t) + defer srv.Close() + + p, err := Status(context.Background(), c, 42, 7) + if err != nil { + t.Fatalf("Status: %v", err) + } + if p == nil { + t.Fatal("Status returned nil progress") + } + if p.Status != "running" || p.Sync != 99 { + t.Errorf("Progress = %+v, want status=running sync=99", p) + } + if len(p.Steps) != 2 { + t.Fatalf("Steps len = %d, want 2", len(p.Steps)) + } + if p.Steps[0].Step != "backup" || p.Steps[1].Status != "running" { + t.Errorf("Steps = %+v, want backup/success + restore/running", p.Steps) + } +} + +func TestPollTerminatesOnSuccess(t *testing.T) { + stub := newStub() + // First call: running. Second call: success. + stub.setSeq("SyncProgress", + `{"data":{"app":{"id":42,"environments":[ + {"id":7,"syncProgress":{"status":"running","sync":1,"steps":[ + {"name":"Backup","status":"running","step":"backup"} + ]}} + ]}}}`, + `{"data":{"app":{"id":42,"environments":[ + {"id":7,"syncProgress":{"status":"success","sync":1,"steps":[ + {"name":"Backup","status":"success","step":"backup"} + ]}} + ]}}}`, + ) + srv, c := stub.start(t) + defer srv.Close() + + var transitions atomic.Int32 + p, err := Poll(context.Background(), c, 42, 7, PollOpts{ + Interval: 1 * time.Millisecond, + OnTransition: func(s Step) { + transitions.Add(1) + }, + }) + if err != nil { + t.Fatalf("Poll: %v", err) + } + if p == nil || p.Status != StatusSuccess { + t.Fatalf("Poll final = %+v, want status=success", p) + } + if n := transitions.Load(); n < 2 { + t.Errorf("transitions = %d, want >= 2 (running then success)", n) + } +} + +func TestPollRespectsCancel(t *testing.T) { + stub := newStub() + // Always running — Poll will never terminate on its own. + stub.setStatic("SyncProgress", `{"data":{"app":{"id":42,"environments":[ + {"id":7,"syncProgress":{"status":"running","sync":1,"steps":[]}} + ]}}}`) + srv, c := stub.start(t) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + _, err := Poll(ctx, c, 42, 7, PollOpts{Interval: 5 * time.Millisecond}) + if err == nil { + t.Fatal("Poll should return ctx error on cancel, got nil") + } + if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want ctx error", err) + } +} + +func TestPollOnErrorTransient(t *testing.T) { + stub := newStub() + // First Status call returns an error; second returns success. + stub.setSeq("SyncProgress", + `{"data":null,"errors":[{"message":"transient blip"}]}`, + `{"data":{"app":{"id":42,"environments":[ + {"id":7,"syncProgress":{"status":"success","sync":1,"steps":[]}} + ]}}}`, + ) + srv, c := stub.start(t) + defer srv.Close() + + var sawErr atomic.Int32 + p, err := Poll(context.Background(), c, 42, 7, PollOpts{ + Interval: 1 * time.Millisecond, + OnError: func(e error) bool { + sawErr.Add(1) + return true // treat as transient + }, + }) + if err != nil { + t.Fatalf("Poll: %v", err) + } + if p == nil || p.Status != StatusSuccess { + t.Fatalf("Poll final = %+v, want status=success", p) + } + if sawErr.Load() == 0 { + t.Errorf("OnError never called; want at least once") + } +} From d0410774c49f21fc9787b02d404b94c38676b3f8 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 20 Aug 2026 18:36:41 -0500 Subject: [PATCH 13/32] feat(go): wp-cli shell, SSH and stream transport Ported from vip-cli-golang@72ef2f89 (feature/go-rewrite). --- internal/wpshell/parser.go | 68 +++ internal/wpshell/parser_test.go | 58 +++ internal/wpshell/repl.go | 81 ++++ internal/wpshell/repl_test.go | 71 +++ internal/wpshell/requote.go | 13 + internal/wpshell/requote_test.go | 15 + internal/wpssh/wpssh.go | 117 +++++ internal/wpssh/wpssh_test.go | 203 +++++++++ internal/wpstream/e2e_test.go | 178 ++++++++ internal/wpstream/engineio.go | 291 +++++++++++++ internal/wpstream/engineio_test.go | 82 ++++ internal/wpstream/iostream.go | 391 +++++++++++++++++ internal/wpstream/iostream_test.go | 138 ++++++ internal/wpstream/run.go | 355 +++++++++++++++ internal/wpstream/run_test.go | 184 ++++++++ internal/wpstream/socketio.go | 435 +++++++++++++++++++ internal/wpstream/socketio_test.go | 402 +++++++++++++++++ internal/wpstream/testdata/fixture-server.js | 54 +++ 18 files changed, 3136 insertions(+) create mode 100644 internal/wpshell/parser.go create mode 100644 internal/wpshell/parser_test.go create mode 100644 internal/wpshell/repl.go create mode 100644 internal/wpshell/repl_test.go create mode 100644 internal/wpshell/requote.go create mode 100644 internal/wpshell/requote_test.go create mode 100644 internal/wpssh/wpssh.go create mode 100644 internal/wpssh/wpssh_test.go create mode 100644 internal/wpstream/e2e_test.go create mode 100644 internal/wpstream/engineio.go create mode 100644 internal/wpstream/engineio_test.go create mode 100644 internal/wpstream/iostream.go create mode 100644 internal/wpstream/iostream_test.go create mode 100644 internal/wpstream/run.go create mode 100644 internal/wpstream/run_test.go create mode 100644 internal/wpstream/socketio.go create mode 100644 internal/wpstream/socketio_test.go create mode 100644 internal/wpstream/testdata/fixture-server.js diff --git a/internal/wpshell/parser.go b/internal/wpshell/parser.go new file mode 100644 index 000000000..c6a87e89b --- /dev/null +++ b/internal/wpshell/parser.go @@ -0,0 +1,68 @@ +// Package wpshell ports the WP-CLI subshell from src/bin/vip-wp.js and +// the DFA command parser from src/lib/wp/helpers.ts. The parser +// accumulates a full WP-CLI command across physical input lines, +// preserving quoted multiline values without shell unescaping. +package wpshell + +type state int + +const ( + s0 state = iota // normal + s1 // after backslash + s2 // inside double quotes + s3 // after backslash inside double quotes + s4 // inside single quotes + ff // final +) + +// CmdState mirrors helpers.ts CmdState. +type CmdState struct { + state state + Command string + Done bool +} + +func NewCmdState() *CmdState { st := &CmdState{}; ResetState(st); return st } + +func ResetState(st *CmdState) { st.state = s0; st.Command = ""; st.Done = false } + +// stateTable: rows = current state, cols = char class [\ " ' \n other]. +// helpers.ts:78. +var stateTable = [6][5]state{ + /* s0 */ {s1, s2, s4, ff, s0}, + /* s1 */ {s0, s0, s0, ff, s0}, + /* s2 */ {s3, s0, s2, s2, s2}, + /* s3 */ {s2, s2, s2, s2, s2}, + /* s4 */ {s4, s4, s0, s4, s4}, + /* ff */ {ff, ff, ff, ff, ff}, +} + +func charClass(r rune) int { + switch r { + case '\\': + return 0 + case '"': + return 1 + case '\'': + return 2 + case '\n': + return 3 + default: + return 4 + } +} + +// StateMachine ports stateMachine (helpers.ts:120): appends a newline to +// the line, then walks each rune. Reaching ff sets Done and stops +// (the terminating newline is NOT appended to Command). +func StateMachine(st *CmdState, line string) { + line += "\n" + for _, r := range line { + st.state = stateTable[st.state][charClass(r)] + if st.state == ff { + st.Done = true + return + } + st.Command += string(r) + } +} diff --git a/internal/wpshell/parser_test.go b/internal/wpshell/parser_test.go new file mode 100644 index 000000000..cbc2de911 --- /dev/null +++ b/internal/wpshell/parser_test.go @@ -0,0 +1,58 @@ +package wpshell + +import "testing" + +func TestStateMachineSingleLine(t *testing.T) { + st := NewCmdState() + StateMachine(st, "wp option get home") + if !st.Done { + t.Fatal("single line should finalize on the trailing newline") + } + if st.Command != "wp option get home" { + t.Errorf("command = %q", st.Command) + } +} + +func TestStateMachineMultilineQuoted(t *testing.T) { + st := NewCmdState() + StateMachine(st, `wp option set mykey "first line`) + if st.Done { + t.Fatal("open double-quote must not finalize") + } + StateMachine(st, `second line"`) + if !st.Done { + t.Fatal("closing quote + newline finalizes") + } + if st.Command != "wp option set mykey \"first line\nsecond line\"" { + t.Errorf("command = %q", st.Command) + } +} + +func TestStateMachineSingleQuotes(t *testing.T) { + st := NewCmdState() + StateMachine(st, `wp eval 'return "x";'`) + if !st.Done || st.Command != `wp eval 'return "x";'` { + t.Errorf("done=%v command=%q", st.Done, st.Command) + } +} + +func TestStateMachineBackslashNotContinuation(t *testing.T) { + // helpers.ts: a backslash before newline is NOT a line continuation. + st := NewCmdState() + StateMachine(st, `wp post list \`) + if !st.Done { + t.Fatalf("backslash-at-eol still terminates (done=%v)", st.Done) + } + if st.Command != `wp post list \` { + t.Errorf("command = %q", st.Command) + } +} + +func TestResetState(t *testing.T) { + st := NewCmdState() + StateMachine(st, "wp x") + ResetState(st) + if st.Done || st.Command != "" { + t.Errorf("reset failed: %+v", st) + } +} diff --git a/internal/wpshell/repl.go b/internal/wpshell/repl.go new file mode 100644 index 000000000..6caa5cccf --- /dev/null +++ b/internal/wpshell/repl.go @@ -0,0 +1,81 @@ +package wpshell + +import ( + "bufio" + "fmt" + "io" + "strings" +) + +// REPL drives the interactive WP-CLI subshell. Run is invoked with each +// finalized command (leading "wp " stripped, matching vip-wp.js:493). +// Serve returns when input reaches EOF or the user types `exit`. +type REPL struct { + Prompt string + Run func(command string) error +} + +// Serve reads lines until EOF / exit. Port of the readline 'line' handler +// (vip-wp.js:445). Non-`wp` first input is rejected; `wp ...` commands are +// accumulated via the DFA across lines. +func (r *REPL) Serve(in *bufio.Reader, out io.Writer) error { + state := NewCmdState() + seenWP := false + + fmt.Fprint(out, r.Prompt) + for { + line, err := in.ReadString('\n') + line = strings.TrimRight(line, "\n") + atEOF := err == io.EOF + + if !atEOF || line != "" { + if r.handleLine(out, state, &seenWP, line) == exitREPL { + return nil + } + } + if atEOF { + return nil + } + } +} + +type lineResult int + +const ( + continueREPL lineResult = iota + exitREPL +) + +func (r *REPL) handleLine(out io.Writer, state *CmdState, seenWP *bool, line string) lineResult { + // Blank line re-prompts (vip-wp.js:451). + if line == "" { + fmt.Fprint(out, r.Prompt) + return continueREPL + } + // exit / exit; quits when not mid-command (vip-wp.js:457). + if !*seenWP && strings.HasPrefix(line, "exit") { + return exitREPL + } + if !*seenWP && strings.HasPrefix(strings.TrimLeft(line, " \t"), "wp ") { + *seenWP = true + ResetState(state) + } + if !*seenWP { + ResetState(state) + fmt.Fprintln(out, "Error: invalid command, please pass a valid WP-CLI command.") + fmt.Fprint(out, r.Prompt) + return continueREPL + } + + StateMachine(state, line) + if !state.Done { + return continueREPL // keep accumulating (multiline quote) + } + + cmd := strings.TrimPrefix(state.Command, "wp ") + *seenWP = false + ResetState(state) + _ = r.Run(cmd) + fmt.Fprint(out, r.Prompt) + return continueREPL +} diff --git a/internal/wpshell/repl_test.go b/internal/wpshell/repl_test.go new file mode 100644 index 000000000..fa7883336 --- /dev/null +++ b/internal/wpshell/repl_test.go @@ -0,0 +1,71 @@ +package wpshell + +import ( + "bufio" + "strings" + "testing" +) + +func TestREPLRunsValidCommand(t *testing.T) { + var ran []string + in := strings.NewReader("wp option get home\nexit\n") + var out strings.Builder + loop := &REPL{ + Prompt: "app.develop:~$ ", + Run: func(cmd string) error { ran = append(ran, cmd); return nil }, + } + if err := loop.Serve(bufio.NewReader(in), &out); err != nil { + t.Fatal(err) + } + if len(ran) != 1 || ran[0] != "option get home" { + t.Errorf("ran = %v (leading 'wp ' must be stripped)", ran) + } +} + +func TestREPLInvalidCommand(t *testing.T) { + in := strings.NewReader("ls -la\nexit\n") + var out strings.Builder + loop := &REPL{Run: func(string) error { t.Fatal("must not run"); return nil }} + _ = loop.Serve(bufio.NewReader(in), &out) + if !strings.Contains(out.String(), "invalid command, please pass a valid WP-CLI command.") { + t.Errorf("out = %q", out.String()) + } +} + +func TestREPLExit(t *testing.T) { + in := strings.NewReader("exit\n") + var out strings.Builder + ran := false + loop := &REPL{Run: func(string) error { ran = true; return nil }} + if err := loop.Serve(bufio.NewReader(in), &out); err != nil { + t.Fatal(err) + } + if ran { + t.Error("exit must not run a command") + } +} + +func TestREPLMultilineCommand(t *testing.T) { + var ran []string + in := strings.NewReader("wp option set k \"line1\nline2\"\nexit\n") + var out strings.Builder + loop := &REPL{Run: func(cmd string) error { ran = append(ran, cmd); return nil }} + if err := loop.Serve(bufio.NewReader(in), &out); err != nil { + t.Fatal(err) + } + if len(ran) != 1 || ran[0] != "option set k \"line1\nline2\"" { + t.Errorf("ran = %v", ran) + } +} + +func TestREPLBlankLineReprompts(t *testing.T) { + in := strings.NewReader("\n\nexit\n") + var out strings.Builder + loop := &REPL{Prompt: "P$ ", Run: func(string) error { return nil }} + if err := loop.Serve(bufio.NewReader(in), &out); err != nil { + t.Fatal(err) + } + if strings.Count(out.String(), "P$ ") < 2 { + t.Errorf("expected multiple prompts, out = %q", out.String()) + } +} diff --git a/internal/wpshell/requote.go b/internal/wpshell/requote.go new file mode 100644 index 000000000..a4d5db518 --- /dev/null +++ b/internal/wpshell/requote.go @@ -0,0 +1,13 @@ +package wpshell + +import "strings" + +// RequoteArgs ports requoteArgs (format.ts:135): wrap each arg in double +// quotes, escaping any inner double quotes. +func RequoteArgs(args []string) []string { + out := make([]string, len(args)) + for i, a := range args { + out[i] = `"` + strings.ReplaceAll(a, `"`, `\"`) + `"` + } + return out +} diff --git a/internal/wpshell/requote_test.go b/internal/wpshell/requote_test.go new file mode 100644 index 000000000..8767a3a87 --- /dev/null +++ b/internal/wpshell/requote_test.go @@ -0,0 +1,15 @@ +package wpshell + +import ( + "slices" + "testing" +) + +func TestRequoteArgs(t *testing.T) { + // format.ts:135 — wrap each arg in double quotes, escaping inner ". + got := RequoteArgs([]string{"post", "list", `--search=a "b" c`}) + want := []string{`"post"`, `"list"`, `"--search=a \"b\" c"`} + if !slices.Equal(got, want) { + t.Errorf("got %v want %v", got, want) + } +} diff --git a/internal/wpssh/wpssh.go b/internal/wpssh/wpssh.go new file mode 100644 index 000000000..b9bb61762 --- /dev/null +++ b/internal/wpssh/wpssh.go @@ -0,0 +1,117 @@ +// Package wpssh ports the SSH WP-CLI execution strategy from +// src/commands/wp-ssh.ts (executeCommandOverSSH, lines 170-258). +// Signal handling is intentionally omitted — that belongs to the command +// layer which owns os.Signal channels. +package wpssh + +import ( + "context" + "errors" + "fmt" + "io" + "net" + + "golang.org/x/crypto/ssh" +) + +// SSH_HANDSHAKE_TIMEOUT_MS matches the Node constant (wp-ssh.ts:22). +const handshakeTimeout = 5e9 // 5 seconds in nanoseconds (time.Duration) + +// Auth carries SSH credentials + command identifiers from the +// TriggerWPCLICommand mutation. Port is a string (schema: String!). +type Auth struct { + Host, Port, Username string + PrivateKey string + Passphrase string + GUID, InputToken string +} + +// Streams injects process stdio (real os.Stdin/out in production). +type Streams struct { + Stdin io.Reader + Stdout, Stderr io.Writer +} + +// Meta carries terminal dimensions + CLI version for the exec preamble. +type Meta struct { + Version string + Rows int + Columns int + TTY bool +} + +// ExitCodeError signals a non-zero remote exit (wp-ssh.ts:59 NonZeroExitCodeError). +type ExitCodeError struct { + Code int + GUID string +} + +func (e *ExitCodeError) Error() string { + return fmt.Sprintf("command failed with exit code %d", e.Code) +} + +// Run connects to the SSH server described by auth, execs the env-var preamble, +// pipes stdio for the duration, and returns any error. +// It is the port of executeCommandOverSSH (wp-ssh.ts:170-258). +func Run(ctx context.Context, auth Auth, streams Streams, meta Meta) error { + signer, err := parseSigner(auth.PrivateKey, auth.Passphrase) + if err != nil { + return fmt.Errorf("wpssh: parse private key: %w", err) + } + + cfg := &ssh.ClientConfig{ + User: auth.Username, + Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, + // Node's ssh2 does not verify host keys; the endpoint and credentials + // originate from the authenticated VIP API, so we replicate that + // behaviour here for Node parity. + HostKeyCallback: ssh.InsecureIgnoreHostKey(), // #nosec G106 + Timeout: handshakeTimeout, + } + + addr := net.JoinHostPort(auth.Host, auth.Port) + client, err := ssh.Dial("tcp", addr, cfg) + if err != nil { + return fmt.Errorf("wpssh: dial %s: %w", addr, err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return fmt.Errorf("wpssh: new session: %w", err) + } + defer session.Close() + + session.Stdin = streams.Stdin + session.Stdout = streams.Stdout + session.Stderr = streams.Stderr + + // Build the env-var preamble exactly as wp-ssh.ts:199 does. + ttyStr := "false" + if meta.TTY { + ttyStr = "true" + } + cmd := fmt.Sprintf( + "GUID=%s INPUT_TOKEN=%s VERSION=%s ROWS=%d COLUMNS=%d TTY=%s", + auth.GUID, auth.InputToken, meta.Version, meta.Rows, meta.Columns, ttyStr, + ) + + if err := session.Run(cmd); err != nil { + var exitErr *ssh.ExitError + if errors.As(err, &exitErr) { + return &ExitCodeError{Code: exitErr.ExitStatus(), GUID: auth.GUID} + } + return fmt.Errorf("wpssh: run: %w", err) + } + return nil +} + +// parseSigner parses an OpenSSH PEM private key, optionally decrypted with +// passphrase (wp-ssh.ts connect options: privateKey + passphrase). +func parseSigner(privateKeyPEM, passphrase string) (ssh.Signer, error) { + keyBytes := []byte(privateKeyPEM) + if passphrase != "" { + return ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(passphrase)) + } + return ssh.ParsePrivateKey(keyBytes) +} diff --git a/internal/wpssh/wpssh_test.go b/internal/wpssh/wpssh_test.go new file mode 100644 index 000000000..f00ef5e5e --- /dev/null +++ b/internal/wpssh/wpssh_test.go @@ -0,0 +1,203 @@ +package wpssh_test + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" + "fmt" + "io" + "net" + "strings" + "testing" + + gossh "golang.org/x/crypto/ssh" + + "github.com/Automattic/vip/internal/wpssh" +) + +// testClientKeyPEM generates a fresh ed25519 private key and returns it as +// an OpenSSH PEM string (the format ssh.ParsePrivateKey accepts). +func testClientKeyPEM(t *testing.T) string { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate ed25519 key: %v", err) + } + block, err := gossh.MarshalPrivateKey(priv, "") + if err != nil { + t.Fatalf("marshal private key: %v", err) + } + return string(pem.EncodeToMemory(block)) +} + +// startEchoSSHServer stands up an in-process SSH server on a random local +// port. On each session it: +// 1. Accepts an "exec" channel request. +// 2. Writes the exec command string to the channel stdout so the test can +// assert the preamble. +// 3. Sends an exit-status reply with exitCode. +// +// Returns host and port strings; t.Cleanup closes the listener. +func startEchoSSHServer(t *testing.T, exitCode int) (host, port string) { + t.Helper() + + // Generate a host key. + _, hostPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate host key: %v", err) + } + hostSigner, err := gossh.NewSignerFromKey(hostPriv) + if err != nil { + t.Fatalf("new host signer: %v", err) + } + + cfg := &gossh.ServerConfig{ + NoClientAuth: true, + } + cfg.AddHostKey(hostSigner) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + addr := ln.Addr().String() + host, port, err = net.SplitHostPort(addr) + if err != nil { + t.Fatalf("split host/port: %v", err) + } + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return // listener closed + } + go handleSSHConn(conn, cfg, exitCode) + } + }() + + return host, port +} + +func handleSSHConn(conn net.Conn, cfg *gossh.ServerConfig, exitCode int) { + srvConn, chans, reqs, err := gossh.NewServerConn(conn, cfg) + if err != nil { + return + } + defer srvConn.Close() + go gossh.DiscardRequests(reqs) + + for newChan := range chans { + if newChan.ChannelType() != "session" { + _ = newChan.Reject(gossh.UnknownChannelType, "unknown channel type") + continue + } + ch, requests, err := newChan.Accept() + if err != nil { + return + } + go handleSession(ch, requests, exitCode) + } +} + +// execPayload is the wire format for an "exec" request payload. +type execPayload struct { + Command string +} + +func handleSession(ch gossh.Channel, requests <-chan *gossh.Request, exitCode int) { + defer ch.Close() + + for req := range requests { + if req.Type != "exec" { + if req.WantReply { + _ = req.Reply(false, nil) + } + continue + } + + // Decode the length-prefixed command string. + var payload execPayload + if err := gossh.Unmarshal(req.Payload, &payload); err != nil { + if req.WantReply { + _ = req.Reply(false, nil) + } + return + } + + if req.WantReply { + _ = req.Reply(true, nil) + } + + // Echo the command string to stdout so the test can assert the preamble. + _, _ = fmt.Fprint(ch, payload.Command) + + // Send exit-status before closing. + exitMsg := gossh.Marshal(struct{ Code uint32 }{uint32(exitCode)}) + _, _ = ch.SendRequest("exit-status", false, exitMsg) + return + } +} + +// --- Tests ------------------------------------------------------------------ + +func TestRunSSHHappyPath(t *testing.T) { + host, port := startEchoSSHServer(t, 0) + var stdout bytes.Buffer + // Use separate writers for stdout and stderr: x/crypto/ssh copies them + // concurrently, and sharing a single bytes.Buffer would race. + err := wpssh.Run(context.Background(), wpssh.Auth{ + Host: host, Port: port, Username: "u", PrivateKey: testClientKeyPEM(t), + GUID: "g1", InputToken: "tok", + }, wpssh.Streams{Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: io.Discard}, + wpssh.Meta{Version: "test", Rows: 15, Columns: 100, TTY: false}) + if err != nil { + t.Fatal(err) + } + // The exec command string carries the env-var preamble (wp-ssh.ts:199). + if !strings.Contains(stdout.String(), "GUID=g1") || + !strings.Contains(stdout.String(), "INPUT_TOKEN=tok") || + !strings.Contains(stdout.String(), "VERSION=test") { + t.Errorf("exec line = %q", stdout.String()) + } +} + +func TestRunSSHNonZeroExit(t *testing.T) { + host, port := startEchoSSHServer(t, 3) + var stdout bytes.Buffer + err := wpssh.Run(context.Background(), wpssh.Auth{ + Host: host, Port: port, Username: "u", PrivateKey: testClientKeyPEM(t), GUID: "g", InputToken: "t", + }, + wpssh.Streams{Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: io.Discard}, + wpssh.Meta{Version: "test", Rows: 15, Columns: 100}) + var ec *wpssh.ExitCodeError + if !errors.As(err, &ec) || ec.Code != 3 { + t.Fatalf("err = %v, want exit-code 3", err) + } +} + +func TestRunSSHRefusedPort(t *testing.T) { + // Find a port that's definitely not listening. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := ln.Addr().String() + ln.Close() // close immediately so the port is refused + + host, port, _ := net.SplitHostPort(addr) + var stdout bytes.Buffer + err = wpssh.Run(context.Background(), wpssh.Auth{ + Host: host, Port: port, Username: "u", PrivateKey: testClientKeyPEM(t), + }, + wpssh.Streams{Stdin: strings.NewReader(""), Stdout: &stdout, Stderr: io.Discard}, + wpssh.Meta{Version: "test", Rows: 15, Columns: 100}) + if err == nil { + t.Fatal("expected error connecting to closed port, got nil") + } +} diff --git a/internal/wpstream/e2e_test.go b/internal/wpstream/e2e_test.go new file mode 100644 index 000000000..7270b1634 --- /dev/null +++ b/internal/wpstream/e2e_test.go @@ -0,0 +1,178 @@ +//go:build wpstream_e2e + +package wpstream + +import ( + "bufio" + "bytes" + "context" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// startFixtureServer spawns the Node socket.io fixture on a free port and waits +// for "LISTENING". Requires Node 22. Env knobs configure behavior. +func startFixtureServer(t *testing.T, env map[string]string) (apiHost string) { + t.Helper() + port := freePort(t) + cmd := exec.Command("node", "internal/wpstream/testdata/fixture-server.js") + cmd.Dir = repoRoot(t) // module root (dir containing go.mod) + cmd.Env = append(envSlice(env), "PORT="+strconv.Itoa(port)) + stdout, _ := cmd.StdoutPipe() + cmd.Stderr = nil + if err := cmd.Start(); err != nil { + t.Skipf("node not available: %v", err) + } + t.Cleanup(func() { _ = cmd.Process.Kill() }) + + sc := bufio.NewScanner(stdout) + ready := make(chan struct{}) + go func() { + for sc.Scan() { + if strings.Contains(sc.Text(), "LISTENING") { + close(ready) + return + } + } + }() + select { + case <-ready: + case <-time.After(10 * time.Second): + t.Fatal("fixture server did not start") + } + return "http://127.0.0.1:" + strconv.Itoa(port) +} + +// freePort finds an available TCP port on localhost. +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("freePort: %v", err) + } + port := l.Addr().(*net.TCPAddr).Port + _ = l.Close() + return port +} + +// repoRoot walks up from the test's working dir until it finds a directory +// containing go.mod (the module root). +func repoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("repoRoot: Getwd: %v", err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("repoRoot: go.mod not found") + } + dir = parent + } +} + +// envSlice starts from os.Environ() and appends k=v for each map entry. +func envSlice(env map[string]string) []string { + base := os.Environ() + for k, v := range env { + base = append(base, k+"="+v) + } + return base +} + +func TestE2EHappyPathStdoutAndExit(t *testing.T) { + apiHost := startFixtureServer(t, map[string]string{ + "SCRIPT_STDOUT": "hello from wp-cli\n", + "EXIT_CODE": "0", + }) + var out bytes.Buffer + res, err := Run(context.Background(), Options{ + APIHost: apiHost, Token: "test-token", + GUID: "g", InputToken: "t", + Stdin: strings.NewReader(""), Stdout: &out, Stderr: &out, + }) + if err != nil { + t.Fatal(err) + } + if res.ExitCode != 0 { + t.Errorf("exit = %d", res.ExitCode) + } + if !strings.Contains(out.String(), "hello from wp-cli") { + t.Errorf("stdout = %q", out.String()) + } +} + +func TestE2ENonZeroExit(t *testing.T) { + apiHost := startFixtureServer(t, map[string]string{ + "SCRIPT_STDOUT": "boom\n", "EXIT_CODE": "3", "EXIT_MESSAGE": "failed", + }) + var out bytes.Buffer + res, err := Run(context.Background(), Options{APIHost: apiHost, Token: "t", GUID: "g", InputToken: "t", + Stdin: strings.NewReader(""), Stdout: &out, Stderr: &out}) + if err != nil { + t.Fatal(err) + } + if res.ExitCode != 3 { + t.Errorf("exit = %d, want 3", res.ExitCode) + } +} + +func TestE2EOffsetResumeAfterKill(t *testing.T) { + apiHost := startFixtureServer(t, map[string]string{ + "SCRIPT_STDOUT": "0123456789ABCDEF", "KILL_AFTER": "8", "EXIT_CODE": "0", + }) + var out bytes.Buffer + res, err := Run(context.Background(), Options{APIHost: apiHost, Token: "t", GUID: "g", InputToken: "t", + Stdin: strings.NewReader(""), Stdout: &out, Stderr: &out}) + if err != nil { + t.Fatal(err) + } + if res.ExitCode != 0 { + t.Errorf("exit = %d", res.ExitCode) + } + if out.String() != "0123456789ABCDEF" { + t.Errorf("resumed stdout = %q, want full payload once", out.String()) + } +} + +// TestE2EOffsetResumeMultipleKills verifies that the reconnect loop handles +// MORE THAN ONE disconnect correctly (C1 fix). The fixture kills the connection +// twice: after 8 bytes on the first attempt (offset=0→8), and after 8 bytes +// on the second attempt (offset=8→16). The third attempt delivers the remaining +// 8 bytes (offset=16→24) and emits exit 0. The full 24-byte payload must appear +// in Stdout exactly once. +func TestE2EOffsetResumeMultipleKills(t *testing.T) { + apiHost := startFixtureServer(t, map[string]string{ + "SCRIPT_STDOUT": "0123456789ABCDEFGHIJKLMN", + "KILL_AFTER": "8", + "KILL_TIMES": "2", + "EXIT_CODE": "0", + }) + var out bytes.Buffer + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + res, err := Run(ctx, Options{ + APIHost: apiHost, Token: "t", GUID: "g", InputToken: "t", + Stdin: strings.NewReader(""), Stdout: &out, Stderr: &out, + }) + if err != nil { + t.Fatal(err) + } + if res.ExitCode != 0 { + t.Errorf("exit = %d, want 0", res.ExitCode) + } + const want = "0123456789ABCDEFGHIJKLMN" + if out.String() != want { + t.Errorf("stdout = %q, want %q", out.String(), want) + } +} diff --git a/internal/wpstream/engineio.go b/internal/wpstream/engineio.go new file mode 100644 index 000000000..ec7981121 --- /dev/null +++ b/internal/wpstream/engineio.go @@ -0,0 +1,291 @@ +// Package wpstream is a hand-rolled Engine.IO v4 + Socket.IO v4 + +// socket.io-stream client. It ports the socket.io transport used by +// src/bin/vip-wp.js for the wpcliStrategy=websocket WP-CLI strategy. +// +// engineio.go is the bottom layer: the Engine.IO v4 transport. It does the +// HTTP long-poll handshake, upgrades to WebSocket, answers server pings, and +// exposes a packet-level duplex to the Socket.IO codec above it. +package wpstream + +import ( + "context" + "encoding/json/v2" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/coder/websocket" + + "github.com/Automattic/vip/internal/httpproxy" +) + +// Engine.IO v4 packet type chars (engine.io-parser commons.js). +const ( + eioOpen = '0' + eioClose = '1' + eioPing = '2' + eioPong = '3' + eioMessage = '4' + eioUpgrade = '5' + eioNoop = '6' +) + +// Packet is one Engine.IO packet. For text packets Type is the type char and +// Data is the payload after it. For raw binary frames (socket.io attachments) +// Binary is true, Type is eioMessage by convention, and Data holds the bytes. +type Packet struct { + Type byte + Data []byte + Binary bool +} + +// DialOptions configure the Engine.IO connection. +type DialOptions struct { + BaseURL string // e.g. https://api.wpvip.com (no trailing /socket.io/) + Header http.Header // extraHeaders carried on BOTH transports (Bearer token) + Client *http.Client +} + +type openPacket struct { + SID string `json:"sid"` + Upgrades []string `json:"upgrades"` + PingInterval int `json:"pingInterval"` + PingTimeout int `json:"pingTimeout"` +} + +// Engine is a connected Engine.IO transport (after the websocket upgrade). +// A background read loop dispatches incoming frames: pings are answered +// immediately, non-ping packets are queued on recvCh for Read callers. +type Engine struct { + ws *websocket.Conn + sid string + pingInterval time.Duration + pingTimeout time.Duration + + recvCh chan Packet + errCh chan error // closed / first error from the read loop + + mu sync.Mutex + closeOnce sync.Once + closed chan struct{} + readCancel context.CancelFunc // cancels the readLoop context (I1) +} + +func (e *Engine) SID() string { return e.sid } + +// Dial performs the polling handshake then upgrades to WebSocket. +func Dial(ctx context.Context, opts DialOptions) (*Engine, error) { + client := opts.Client + if client == nil { + // vip-wp.js:539 passes createProxyAgent(API_HOST) to the socket.io + // client, so this transport is proxied on Node too — but by Node's + // policy, not http.DefaultTransport's. See internal/httpproxy. + client = httpproxy.Client() + } + + // 1. Polling handshake: GET /socket.io/?EIO=4&transport=polling + open, err := pollingHandshake(ctx, client, opts) + if err != nil { + return nil, err + } + + // 2. WebSocket upgrade with sid. + wsURL := buildURL(opts.BaseURL, "websocket", open.SID) + c, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{ + HTTPClient: client, + HTTPHeader: opts.Header, + }) + if err != nil { + return nil, fmt.Errorf("wpstream: websocket dial: %w", err) + } + c.SetReadLimit(-1) // server controls payload size; no client cap + + // 3. Probe: send "2probe", expect "3probe", send "5". + if err := c.Write(ctx, websocket.MessageText, []byte("2probe")); err != nil { + return nil, err + } + _, resp, err := c.Read(ctx) + if err != nil || string(resp) != "3probe" { + return nil, fmt.Errorf("wpstream: bad probe reply %q (%v)", resp, err) + } + if err := c.Write(ctx, websocket.MessageText, []byte{eioUpgrade}); err != nil { + return nil, err + } + + // I1: derive a cancelable context so Close() can stop readLoop. + readCtx, readCancel := context.WithCancel(context.Background()) + + eng := &Engine{ + ws: c, + sid: open.SID, + pingInterval: time.Duration(open.PingInterval) * time.Millisecond, + pingTimeout: time.Duration(open.PingTimeout) * time.Millisecond, + recvCh: make(chan Packet, 64), + errCh: make(chan error, 1), + closed: make(chan struct{}), + readCancel: readCancel, + } + go eng.readLoop(readCtx) + return eng, nil +} + +// readLoop is the single goroutine that owns the websocket read side. +// It answers server pings immediately (serialized through the write mutex) +// and queues all other packets onto recvCh. +// I1: ctx is derived from a cancelable context created in Dial; Close() cancels +// it, which unblocks e.ws.Read and terminates the goroutine cleanly. +func (e *Engine) readLoop(ctx context.Context) { + defer close(e.errCh) + for { + typ, data, err := e.ws.Read(ctx) + if err != nil { + select { + case e.errCh <- err: + default: + } + return + } + if typ == websocket.MessageBinary { + select { + case e.recvCh <- Packet{Type: eioMessage, Data: data, Binary: true}: + case <-e.closed: + return + } + continue + } + if len(data) == 0 { + continue + } + switch data[0] { + case eioPing: + // Server-initiated heartbeat: reply with pong immediately. + _ = e.write(context.Background(), websocket.MessageText, []byte{eioPong}) + case eioNoop: + // skip + case eioClose: + select { + case e.errCh <- io.EOF: + default: + } + return + default: + select { + case e.recvCh <- Packet{Type: data[0], Data: data[1:]}: + case <-e.closed: + return + } + } + } +} + +func pollingHandshake(ctx context.Context, client *http.Client, opts DialOptions) (*openPacket, error) { + u := buildURL(opts.BaseURL, "polling", "") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + for k, vs := range opts.Header { + for _, v := range vs { + req.Header.Add(k, v) + } + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("wpstream: polling handshake: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + // First packet of the (possibly \x1e-joined) payload is the open packet. + first := body + if i := strings.IndexByte(string(body), '\x1e'); i >= 0 { + first = body[:i] + } + if len(first) == 0 || first[0] != eioOpen { + return nil, fmt.Errorf("wpstream: expected open packet, got %q", first) + } + var op openPacket + if err := json.Unmarshal(first[1:], &op); err != nil { + return nil, fmt.Errorf("wpstream: parse open packet: %w", err) + } + return &op, nil +} + +// buildURL constructs /socket.io/?EIO=4&transport=[&sid=] +// with ws/wss scheme for the websocket transport. +func buildURL(base, transport, sid string) string { + u, _ := url.Parse(base) + u.Path = strings.TrimRight(u.Path, "/") + "/socket.io/" + if transport == "websocket" { + switch u.Scheme { + case "https": + u.Scheme = "wss" + case "http": + u.Scheme = "ws" + } + } + q := u.Query() + q.Set("EIO", "4") + q.Set("transport", transport) + if sid != "" { + q.Set("sid", sid) + } + u.RawQuery = q.Encode() + return u.String() +} + +// Read returns the next non-heartbeat packet. Blocks until a packet arrives, +// the context is cancelled, or the connection is closed. +func (e *Engine) Read(ctx context.Context) (Packet, error) { + select { + case pkt, ok := <-e.recvCh: + if !ok { + return Packet{}, errClosed + } + return pkt, nil + case err, ok := <-e.errCh: + if !ok { + return Packet{}, errClosed + } + return Packet{}, err + case <-ctx.Done(): + return Packet{}, ctx.Err() + case <-e.closed: + return Packet{}, errClosed + } +} + +// WriteMessage sends a Socket.IO message packet ("4" + payload). +func (e *Engine) WriteMessage(ctx context.Context, payload []byte) error { + frame := append([]byte{eioMessage}, payload...) + return e.write(ctx, websocket.MessageText, frame) +} + +// WriteBinary sends a raw binary attachment frame (EIO4: no type prefix). +func (e *Engine) WriteBinary(ctx context.Context, data []byte) error { + return e.write(ctx, websocket.MessageBinary, data) +} + +func (e *Engine) write(ctx context.Context, typ websocket.MessageType, data []byte) error { + e.mu.Lock() + defer e.mu.Unlock() + return e.ws.Write(ctx, typ, data) +} + +func (e *Engine) Close() error { + e.closeOnce.Do(func() { + // I1: cancel the readLoop context so ws.Read unblocks immediately. + e.readCancel() + close(e.closed) + }) + return e.ws.Close(websocket.StatusNormalClosure, "") +} + +var errClosed = errors.New("wpstream: engine closed") diff --git a/internal/wpstream/engineio_test.go b/internal/wpstream/engineio_test.go new file mode 100644 index 000000000..e383c8186 --- /dev/null +++ b/internal/wpstream/engineio_test.go @@ -0,0 +1,82 @@ +package wpstream + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/coder/websocket" +) + +// fakeEIOServer serves the EIO4 polling handshake then accepts a websocket +// upgrade, completes the 2probe/5 dance, and sends one message packet. +func fakeEIOServer(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/socket.io/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("transport") == "polling" { + // Open packet: type '0' + JSON handshake. + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") + _, _ = w.Write([]byte(`0{"sid":"abc","upgrades":["websocket"],"pingInterval":300,"pingTimeout":200,"maxPayload":1000000}`)) + return + } + // websocket transport + c, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer c.Close(websocket.StatusNormalClosure, "") + ctx := r.Context() + // Expect "2probe", reply "3probe". + _, probe, _ := c.Read(ctx) + if string(probe) != "2probe" { + t.Errorf("probe = %q", probe) + } + _ = c.Write(ctx, websocket.MessageText, []byte("3probe")) + // Expect "5" (upgrade). + _, up, _ := c.Read(ctx) + if string(up) != "5" { + t.Errorf("upgrade = %q", up) + } + // Send one message packet "4hello". + _ = c.Write(ctx, websocket.MessageText, []byte("4hello")) + // Then a server ping "2"; expect pong "3". + _ = c.Write(ctx, websocket.MessageText, []byte("2")) + _, pong, _ := c.Read(ctx) + if string(pong) != "3" { + t.Errorf("pong = %q", pong) + } + time.Sleep(20 * time.Millisecond) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestEngineIOHandshakeAndUpgrade(t *testing.T) { + srv := fakeEIOServer(t) + eng, err := Dial(context.Background(), DialOptions{ + BaseURL: srv.URL, + Header: http.Header{"Authorization": {"Bearer tok"}}, + }) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer eng.Close() + + if eng.SID() != "abc" { + t.Errorf("sid = %q, want abc", eng.SID()) + } + pkt, err := eng.Read(context.Background()) + if err != nil { + t.Fatalf("Read: %v", err) + } + if pkt.Type != '4' || string(pkt.Data) != "hello" { + t.Errorf("pkt = %c%q", pkt.Type, pkt.Data) + } + // The transport must auto-answer the server ping with a pong (asserted + // server-side). Give the heartbeat goroutine a moment. + time.Sleep(30 * time.Millisecond) +} diff --git a/internal/wpstream/iostream.go b/internal/wpstream/iostream.go new file mode 100644 index 000000000..0f7891fe2 --- /dev/null +++ b/internal/wpstream/iostream.go @@ -0,0 +1,391 @@ +package wpstream + +import ( + "context" + "fmt" + "io" + "sync" + + "github.com/google/uuid" +) + +const streamEvent = "$stream" // socket.io-stream EVENT_NAME + +// StreamSocket wraps a Client to provide the socket.io-stream subprotocol. +// Port of socket.io-stream/lib/socket.js. +type StreamSocket struct { + cli *Client + ctx context.Context + + mu sync.Mutex + streams map[string]*IOStream + handlers map[string][]func(args []any, ackID *int) +} + +func NewStreamSocket(ctx context.Context, cli *Client) *StreamSocket { + ss := &StreamSocket{ + cli: cli, ctx: ctx, + streams: map[string]*IOStream{}, + handlers: map[string][]func([]any, *int){}, + } + cli.On(streamEvent, func(args []any) { ss.onStreamEvent(args) }) + cli.OnRaw(streamEvent+"-write", ss.onWrite) // needs ack id + cli.On(streamEvent+"-read", func(a []any) { ss.onRead(a) }) + cli.On(streamEvent+"-end", func(a []any) { ss.onEnd(a) }) + cli.On(streamEvent+"-error", func(a []any) { ss.onError(a) }) + return ss +} + +func (ss *StreamSocket) On(event string, h func(args []any, ackID *int)) { + ss.mu.Lock() + ss.handlers[event] = append(ss.handlers[event], h) + ss.mu.Unlock() +} + +func (ss *StreamSocket) CreateStream() *IOStream { + s := newIOStream(ss, uuid.NewString()) + ss.register(s) + return s +} + +func (ss *StreamSocket) register(s *IOStream) { + ss.mu.Lock() + ss.streams[s.id] = s + ss.mu.Unlock() +} + +// cleanup removes a stream from the map (M2 — prevents leak across reconnects). +func (ss *StreamSocket) cleanup(id string) { + ss.mu.Lock() + delete(ss.streams, id) + ss.mu.Unlock() +} + +// abortAll snapshots the streams map and aborts each one (C3). +func (ss *StreamSocket) abortAll(err error) { + ss.mu.Lock() + snapshot := make([]*IOStream, 0, len(ss.streams)) + for _, s := range ss.streams { + snapshot = append(snapshot, s) + } + ss.mu.Unlock() + for _, s := range snapshot { + s.abort(err) + } +} + +func (ss *StreamSocket) Emit(ctx context.Context, event string, args []any, ack func([]any)) error { + enc := make([]any, 0, len(args)) + for _, a := range args { + enc = append(enc, ss.encodeArg(a)) + } + full := append([]any{event}, enc...) + return ss.cli.Emit(ctx, streamEvent, full, ack) +} + +func (ss *StreamSocket) encodeArg(v any) any { + switch t := v.(type) { + case *IOStream: + ss.register(t) + return map[string]any{"$stream": t.id} + case []any: + out := make([]any, len(t)) + for i, e := range t { + out[i] = ss.encodeArg(e) + } + return out + case map[string]any: + out := make(map[string]any, len(t)) + for k, e := range t { + out[k] = ss.encodeArg(e) + } + return out + default: + return v + } +} + +func (ss *StreamSocket) decodeArg(v any) any { + switch t := v.(type) { + case map[string]any: + if id, ok := t["$stream"].(string); ok && id != "" { + s := newIOStream(ss, id) + ss.register(s) + return s + } + for k, e := range t { + t[k] = ss.decodeArg(e) + } + return t + case []any: + for i, e := range t { + t[i] = ss.decodeArg(e) + } + return t + default: + return v + } +} + +func (ss *StreamSocket) onStreamEvent(args []any) { + if len(args) == 0 { + return + } + event, _ := args[0].(string) + rest := make([]any, 0, len(args)-1) + for _, a := range args[1:] { + rest = append(rest, ss.decodeArg(a)) + } + ss.mu.Lock() + hs := append([]func([]any, *int){}, ss.handlers[event]...) + ss.mu.Unlock() + // Dispatch user handlers in goroutines so the Client readLoop is not + // blocked. If a handler calls io.ReadAll on an IOStream, it will itself + // emit $stream-read credits, which must be processed by this same readLoop + // — dispatching inline would deadlock. + for _, h := range hs { + h := h + go h(rest, nil) + } +} + +func (ss *StreamSocket) sendRead(id string, size int) { + _ = ss.cli.Emit(ss.ctx, streamEvent+"-read", []any{id, size}, nil) +} + +// sendWrite sends a $stream-write packet. Returns any transport error (M1). +func (ss *StreamSocket) sendWrite(id string, chunk []byte, ack func([]any)) error { + return ss.cli.Emit(ss.ctx, streamEvent+"-write", + []any{id, binaryArg(chunk), "buffer"}, ack) +} + +func (ss *StreamSocket) sendEnd(id string) { + _ = ss.cli.Emit(ss.ctx, streamEvent+"-end", []any{id}, nil) +} + +func (ss *StreamSocket) get(id string) *IOStream { + ss.mu.Lock() + defer ss.mu.Unlock() + return ss.streams[id] +} + +func (ss *StreamSocket) onRead(args []any) { + id, _ := args[0].(string) + if s := ss.get(id); s != nil { + s.grantWriteCredit() + } +} + +func (ss *StreamSocket) onWrite(args []any, ackID *int) { + id, _ := args[0].(string) + var chunk []byte + if b, ok := args[1].([]byte); ok { + chunk = b + } + s := ss.get(id) + if s == nil { + return + } + // deliver blocks until the consumer reads — this is intentional backpressure. + // The ack fires AFTER deliver returns (matching Node socket.io-stream semantics: + // the callback fires after the consumer pulls). The readLoop goroutine may block + // here, but since the ack is sent via WriteMessage (a buffered channel push to + // the peer) and the peer's readLoop is independent, there is no circular wait. + s.deliver(chunk) + if ackID != nil { + _ = ss.cli.ackReply(ss.ctx, *ackID, nil) + } +} + +func (ss *StreamSocket) onEnd(args []any) { + id, _ := args[0].(string) + if s := ss.get(id); s != nil { + s.deliverEOF() + } +} + +func (ss *StreamSocket) onError(args []any) { + id, _ := args[0].(string) + msg := "" + if len(args) > 1 { + msg, _ = args[1].(string) + } + if s := ss.get(id); s != nil { + s.abort(fmt.Errorf("wpstream: remote stream error: %s", msg)) + } +} + +// IOStream is a duplex stream over the socket.io-stream subprotocol. +// It implements io.ReadWriteCloser. +// +// Flow control: reading triggers a $stream-read credit to the remote sender; +// the sender waits for that credit before flushing one chunk via $stream-write. +// Write blocks until a credit arrives (from the remote reader calling Read) and +// until the remote acknowledges receipt (ack from $stream-write handler). +// +// Teardown: abort(err) closes the `closed` channel, unblocking all blocked +// Read/Write/deliver calls. deliverEOF() is a normal end-of-stream; it uses a +// separate sync.Once-guarded readEOF channel so buffered data can still drain. +type IOStream struct { + ss *StreamSocket + id string + + readBuf chan []byte + readEOF chan struct{} + leftover []byte + readReqd bool + + writeCredit chan struct{} + + // closed is closed once by abort() or by the explicit Close() teardown path. + // It is the escape hatch for blocked Read/Write/deliver calls. + closed chan struct{} + closeOnce sync.Once // guards close(closed) + cleanup + abortErr error // set before close(closed); nil means normal close / EOF + + // eofOnce guards close(readEOF) so a duplicate $stream-end never panics (I2). + eofOnce sync.Once + + // sendEndOnce ensures $stream-end is sent exactly once by Close(). + sendEndOnce sync.Once + + mu sync.Mutex +} + +// Ensure IOStream satisfies io.ReadWriteCloser at compile time. +var _ io.ReadWriteCloser = (*IOStream)(nil) + +func newIOStream(ss *StreamSocket, id string) *IOStream { + return &IOStream{ + ss: ss, id: id, + readBuf: make(chan []byte, 1), + readEOF: make(chan struct{}), + writeCredit: make(chan struct{}, 1), + closed: make(chan struct{}), + } +} + +// abort terminates the stream with the given error, unblocking all blocked +// Read/Write/deliver calls. Idempotent (I2, I3). Called on disconnect (C3) +// and on remote stream error. +func (s *IOStream) abort(err error) { + s.closeOnce.Do(func() { + s.abortErr = err + close(s.closed) + s.ss.cleanup(s.id) + }) +} + +// abortErrOrClosed returns the abort error, or io.ErrClosedPipe if the +// stream was closed without an error (normal Close path). +func (s *IOStream) abortErrOrClosed() error { + if s.abortErr != nil { + return s.abortErr + } + return io.ErrClosedPipe +} + +// Read implements io.Reader. On the first call (or after consuming a previous +// chunk) it sends a $stream-read credit to the remote, then blocks until a +// chunk, EOF, or error arrives. Also unblocks when the stream is aborted (I3). +func (s *IOStream) Read(p []byte) (int, error) { + if len(s.leftover) > 0 { + n := copy(p, s.leftover) + s.leftover = s.leftover[n:] + return n, nil + } + s.mu.Lock() + if !s.readReqd { + s.readReqd = true + s.mu.Unlock() + s.ss.sendRead(s.id, len(p)) + } else { + s.mu.Unlock() + } + select { + case chunk := <-s.readBuf: + s.mu.Lock() + s.readReqd = false + s.mu.Unlock() + n := copy(p, chunk) + if n < len(chunk) { + s.leftover = chunk[n:] + } + return n, nil + case <-s.readEOF: + return 0, io.EOF + case <-s.closed: + // drain any chunk that raced with abort + select { + case chunk := <-s.readBuf: + s.mu.Lock() + s.readReqd = false + s.mu.Unlock() + n := copy(p, chunk) + if n < len(chunk) { + s.leftover = chunk[n:] + } + return n, nil + default: + } + return 0, s.abortErrOrClosed() + } +} + +// deliver pushes one chunk into the read buffer. Called by onWrite on the +// readLoop goroutine. Does not block forever after abort (I2). +func (s *IOStream) deliver(chunk []byte) { + select { + case s.readBuf <- chunk: + case <-s.closed: + } +} + +// deliverEOF signals EOF to any blocked Read call. Idempotent (I2). +func (s *IOStream) deliverEOF() { + s.eofOnce.Do(func() { close(s.readEOF) }) +} + +// grantWriteCredit unblocks one pending Write call. +func (s *IOStream) grantWriteCredit() { + select { + case s.writeCredit <- struct{}{}: + default: + } +} + +// Write implements io.Writer. Blocks until a read-credit arrives from the +// remote (i.e., the remote called Read, which sent $stream-read), then sends +// the chunk and blocks until the remote ACKs receipt. +// Both waits are escapable via the closed channel (I3). Emit errors are +// propagated (M1). +func (s *IOStream) Write(p []byte) (int, error) { + select { + case <-s.writeCredit: + case <-s.closed: + return 0, s.abortErrOrClosed() + } + acked := make(chan struct{}) + if err := s.ss.sendWrite(s.id, p, func([]any) { close(acked) }); err != nil { + return 0, err + } + select { + case <-acked: + case <-s.closed: + return 0, s.abortErrOrClosed() + } + return len(p), nil +} + +// Close implements io.Closer. Sends $stream-end exactly once and marks the +// stream closed. Idempotent. +func (s *IOStream) Close() error { + s.sendEndOnce.Do(func() { s.ss.sendEnd(s.id) }) + // Also mark closed so any concurrent Write/Read unblocks (I3). + s.closeOnce.Do(func() { + // abortErr stays nil → abortErrOrClosed returns io.ErrClosedPipe. + close(s.closed) + s.ss.cleanup(s.id) + }) + return nil +} diff --git a/internal/wpstream/iostream_test.go b/internal/wpstream/iostream_test.go new file mode 100644 index 000000000..43431fb63 --- /dev/null +++ b/internal/wpstream/iostream_test.go @@ -0,0 +1,138 @@ +package wpstream + +import ( + "bytes" + "context" + "io" + "testing" + "time" +) + +// pipeTransport is an in-memory transport pair for loopback tests. +// WriteMessage/WriteBinary push packets onto the PEER's inbound channel, +// exactly mirroring what Engine.Read would return after stripping the EIO framing. +type pipeTransport struct { + peer *pipeTransport + in chan Packet +} + +func (p *pipeTransport) Read(ctx context.Context) (Packet, error) { + select { + case pkt := <-p.in: + return pkt, nil + case <-ctx.Done(): + return Packet{}, ctx.Err() + } +} + +// WriteMessage receives the raw socket.io packet string (Client.sendPacket +// passes f.Data which is the sio string; real Engine.WriteMessage adds the +// '4' prefix). We deliver to the peer as Packet{Type: eioMessage, Data: copy} +// which is exactly what Engine.Read returns after stripping the '4'. +func (p *pipeTransport) WriteMessage(ctx context.Context, payload []byte) error { + buf := make([]byte, len(payload)) + copy(buf, payload) + select { + case p.peer.in <- Packet{Type: eioMessage, Data: buf}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// WriteBinary receives raw binary attachment bytes. We deliver to the peer as +// Packet{Binary: true, Data: copy}, matching what Engine.Read returns for a +// WebSocket binary frame. +func (p *pipeTransport) WriteBinary(ctx context.Context, data []byte) error { + buf := make([]byte, len(data)) + copy(buf, data) + select { + case p.peer.in <- Packet{Type: eioMessage, Data: buf, Binary: true}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// newLoopbackStreamSockets builds an in-memory loopback: two pipeTransports +// cross-linked, two Clients with their readLoops running, wrapped in StreamSockets. +// No Connect handshake is needed — we skip directly to readLoop. +func newLoopbackStreamSockets(t *testing.T) (*StreamSocket, *StreamSocket) { + t.Helper() + + ta := &pipeTransport{in: make(chan Packet, 64)} + tb := &pipeTransport{in: make(chan Packet, 64)} + ta.peer = tb + tb.peer = ta + + cliA := NewClient(ta, "/wp-cli") + cliB := NewClient(tb, "/wp-cli") + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + go cliA.readLoop(ctx) + go cliB.readLoop(ctx) + + a := NewStreamSocket(ctx, cliA) + b := NewStreamSocket(ctx, cliB) + return a, b +} + +func TestStreamReadFromRemote(t *testing.T) { + a, b := newLoopbackStreamSockets(t) + + bStream := b.CreateStream() + got := make(chan []byte, 1) + a.On("cmd", func(args []any, _ *int) { + s := args[len(args)-1].(*IOStream) + data, _ := io.ReadAll(s) + got <- data + }) + + ctx := context.Background() + if err := b.Emit(ctx, "cmd", []any{"meta", bStream}, nil); err != nil { + t.Fatal(err) + } + go func() { + _, _ = bStream.Write([]byte("hello")) + _ = bStream.Close() + }() + + select { + case d := <-got: + if !bytes.Equal(d, []byte("hello")) { + t.Errorf("read = %q", d) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout reading remote stream") + } +} + +func TestStreamWriteToRemote(t *testing.T) { + a, b := newLoopbackStreamSockets(t) + aStream := a.CreateStream() + + done := make(chan []byte, 1) + b.On("cmd", func(args []any, _ *int) { + s := args[len(args)-1].(*IOStream) + data, _ := io.ReadAll(s) + done <- data + }) + ctx := context.Background() + if err := a.Emit(ctx, "cmd", []any{"meta", aStream}, nil); err != nil { + t.Fatal(err) + } + go func() { + _, _ = aStream.Write([]byte("from-a")) + _ = aStream.Close() + }() + select { + case d := <-done: + if !bytes.Equal(d, []byte("from-a")) { + t.Errorf("got %q", d) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout") + } +} diff --git a/internal/wpstream/run.go b/internal/wpstream/run.go new file mode 100644 index 000000000..d0b08d5e2 --- /dev/null +++ b/internal/wpstream/run.go @@ -0,0 +1,355 @@ +package wpstream + +import ( + "context" + "encoding/json/v2" + "errors" + "fmt" + "io" + "math" + "math/rand/v2" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/fatih/color" +) + +const ( + nonTTYColumns = 100 // NON_TTY_COLUMNS (vip-wp.js:42) + nonTTYRows = 15 // NON_TTY_ROWS (vip-wp.js:43) +) + +// errRunDone is injected into a stdout IOStream to interrupt a blocked Read +// when run() is about to return (exit/cancel received before stdout EOF). +var errRunDone = errors.New("wpstream: run done") + +// Options configure a single Run. +type Options struct { + APIHost string + Token string + GUID string + InputToken string + Columns int + Rows int + IsTTY bool // CR→LF stdin normalization when true (vip-wp.js:51) + + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// Result carries the terminal outcome (the caller maps ExitCode to os.Exit). +type Result struct { + ExitCode int +} + +// Run connects and executes one WP-CLI command over socket.io. +// It implements the reconnect/offset loop: on disconnect (before an exit event +// arrives) it re-dials with exponential backoff and resumes from offset. +// +// C1/C2 fix: the loop is entirely self-contained; every Engine is explicitly +// closed before the next attempt or before returning. No goroutine is launched +// that outlives its engine. +func Run(ctx context.Context, opts Options) (Result, error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + var offset atomic.Int64 + backoff := time.Second + const maxBackoff = 5 * time.Second + first := true + + for { + eng, err := Dial(ctx, DialOptions{ + BaseURL: opts.APIHost, + Header: bearerHeader(opts.Token), + }) + if err != nil { + if first { + return Result{}, err + } + if werr := waitBackoff(ctx, &backoff, maxBackoff); werr != nil { + return Result{}, werr + } + continue + } + + cli := NewClient(eng, "/wp-cli") + ss := NewStreamSocket(ctx, cli) + + // Install a retry handler: the server sends "retry" to signal it wants us to + // reconnect. We close the engine after 5 s to force the disconnect. + cli.On("retry", func(args []any) { + go func() { + select { + case <-time.After(5 * time.Second): + case <-ctx.Done(): + return + } + eng.Close() + }() + }) + + if err := cli.Connect(ctx); err != nil { + eng.Close() + if first { + return Result{}, err + } + if werr := waitBackoff(ctx, &backoff, maxBackoff); werr != nil { + return Result{}, werr + } + continue + } + first = false + + res, clean := runOnce(ctx, opts, cli, ss, &offset, offset.Load()) + eng.Close() // C2: ALWAYS close engine before next attempt or return + + if ctx.Err() != nil { + return Result{}, ctx.Err() + } + if clean { + return res, nil + } + + // disconnected mid-command → wait then reconnect from offset + if werr := waitBackoff(ctx, &backoff, maxBackoff); werr != nil { + return Result{}, werr + } + backoff = time.Second // reset after a successful (even if interrupted) attempt + } +} + +// waitBackoff sleeps for a jittered backoff duration, then doubles backoff up +// to max. Returns ctx.Err() if the context is cancelled during the wait. +func waitBackoff(ctx context.Context, backoff *time.Duration, max time.Duration) error { + jitter := time.Duration(float64(*backoff) * (0.5 + rand.Float64()*0.5)) + select { + case <-time.After(jitter): + case <-ctx.Done(): + return ctx.Err() + } + *backoff = time.Duration(math.Min(float64(*backoff*2), float64(max))) + return nil +} + +// runOnce executes one attempt: registers handlers, launches the command, +// pipes stdio, and waits for exit, stdout EOF, disconnect, or ctx cancel. +// +// Returns (result, true) on clean exit, or (Result{}, false) on disconnect +// so the caller can reconnect. ctx cancel is treated as clean=true and the +// caller checks ctx.Err() afterwards. +func runOnce(ctx context.Context, opts Options, cli *Client, ss *StreamSocket, offset *atomic.Int64, resumeAt int64) (Result, bool) { + // exitCh is buffered: handlers run in goroutines and MUST NOT block on send. + exitCh := make(chan int, 4) + signalExit := func(code int) { + select { + case exitCh <- code: + default: + } + } + + cli.On("unauthorized", func(args []any) { + fmt.Fprintln(opts.Stdout, "There was an error with the authentication:", errMessage(args)) + }) + cli.On("cancel", func(args []any) { + fmt.Fprintf(opts.Stdout, "Cancel received from server: %s\n", strArg(args)) + signalExit(1) + }) + cli.On("error", func(args []any) { + if strArg(args) == "Rate limit exceeded" { + fmt.Fprintln(opts.Stdout, color.RedString("\nError:"), + "Rate limit exceeded: Please wait a moment and try again.") + return + } + fmt.Fprintln(opts.Stdout, strArg(args)) + }) + cli.On("exit", func(args []any) { + code, msg := parseExit(args) + if msg != "" { + fmt.Fprintln(opts.Stdout, msg) + } + signalExit(code) + }) + + disconnected := make(chan struct{}, 1) + cli.On("disconnect", func(args []any) { + select { + case disconnected <- struct{}{}: + default: + } + }) + + stdoutDone := make(chan struct{}) + // runDone is closed when runOnce() is about to return, allowing the stdout + // goroutine to exit even if the server never closes the stdout stream. + runDone := make(chan struct{}) + var runDoneOnce sync.Once + closeRunDone := func() { runDoneOnce.Do(func() { close(runDone) }) } + + stdinStream := ss.CreateStream() + stdoutStream := ss.CreateStream() + + cols := opts.Columns + if cols == 0 { + cols = nonTTYColumns + } + rows := opts.Rows + if rows == 0 { + rows = nonTTYRows + } + data := map[string]any{ + "guid": opts.GUID, + "inputToken": opts.InputToken, + "columns": cols, + "rows": rows, + "offset": resumeAt, + } + _ = ss.Emit(ctx, "cmd", []any{data, stdinStream, stdoutStream}, nil) + + // Pipe stdin → stdinStream. + // When stdinStream is aborted (C3) Write returns an error, io.Copy stops, + // and the goroutine exits — no leak. + go func() { + src := opts.Stdin + if opts.IsTTY && src != nil { + src = crToLF{src} + } + if src != nil { + _, _ = io.Copy(stdinStream, src) + } + _ = stdinStream.Close() + }() + + // Background watcher: inject errRunDone into stdoutStream when runOnce is + // about to return, unblocking the stdout goroutine. + go func() { + select { + case <-runDone: + stdoutStream.abort(errRunDone) + case <-stdoutDone: + } + }() + // Pipe stdoutStream → opts.Stdout, tracking byte offset. + go func() { + buf := make([]byte, 32*1024) + for { + n, rerr := stdoutStream.Read(buf) + if n > 0 { + _, _ = opts.Stdout.Write(buf[:n]) + if offset != nil { + offset.Add(int64(n)) + } + } + if rerr != nil { + close(stdoutDone) + return + } + } + }() + + defer closeRunDone() + + var exitCode int + select { + case exitCode = <-exitCh: + // Drain stdout: signal runDone so the watcher goroutine aborts the + // stream, unblocking the stdout goroutine. + closeRunDone() + <-stdoutDone + return Result{ExitCode: exitCode}, true + case <-stdoutDone: + // stdout EOF before any exit event — the server always sends an 'exit' + // event after streaming completes (vip-wp.js:261). Wait briefly for it + // so a non-zero exit code is not silently lost. + select { + case exitCode = <-exitCh: + case <-time.After(2 * time.Second): + case <-ctx.Done(): + } + return Result{ExitCode: exitCode}, true + case <-disconnected: + // Transport dropped mid-command. + // C3: abort all in-flight streams so blocked Read/Write goroutines exit. + // We do this here (not in NewStreamSocket) to ensure the disconnected + // channel is selected BEFORE stdoutDone can fire — preventing the race + // where stdoutDone fires first and runOnce returns clean=true incorrectly. + ss.abortAll(io.ErrUnexpectedEOF) + closeRunDone() // also aborts stdoutStream via watcher (redundant but safe) + return Result{}, false + case <-ctx.Done(): + return Result{}, true // caller checks ctx.Err() + } +} + +// run is the internal single-attempt function used by the unit tests (run_test.go). +// The public API uses runOnce via Run. Kept for backward compatibility with tests. +func run(ctx context.Context, opts Options, cli *Client, ss *StreamSocket, offset *atomic.Int64, resumeAt int64) (Result, error) { + res, clean := runOnce(ctx, opts, cli, ss, offset, resumeAt) + if !clean { + // disconnect treated as context cancellation for unit-test callers + return Result{}, ctx.Err() + } + if ctx.Err() != nil { + return Result{}, ctx.Err() + } + return res, nil +} + +// crToLF replaces '\r' with '\n' (normalizeNewlineStream, vip-wp.js:51). +type crToLF struct{ r io.Reader } + +func (c crToLF) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + for i := 0; i < n; i++ { + if p[i] == '\r' { + p[i] = '\n' + } + } + return n, err +} + +func bearerHeader(token string) http.Header { + return http.Header{"Authorization": {"Bearer " + token}} +} + +func parseExit(args []any) (int, string) { + if len(args) == 0 { + return 0, "" + } + m, ok := args[0].(map[string]any) + if !ok { + return 0, "" + } + code := 0 + if c, ok := m["exitCode"].(float64); ok { + code = int(c) + } + msg, _ := m["message"].(string) + return code, msg +} + +func strArg(args []any) string { + if len(args) == 0 { + return "" + } + if s, ok := args[0].(string); ok { + return s + } + b, _ := json.Marshal(args[0]) + return string(b) +} + +func errMessage(args []any) string { + if len(args) == 0 { + return "" + } + if m, ok := args[0].(map[string]any); ok { + if s, ok := m["message"].(string); ok { + return s + } + } + return strArg(args) +} diff --git a/internal/wpstream/run_test.go b/internal/wpstream/run_test.go new file mode 100644 index 000000000..655406c05 --- /dev/null +++ b/internal/wpstream/run_test.go @@ -0,0 +1,184 @@ +package wpstream + +import ( + "bytes" + "context" + "strings" + "sync/atomic" + "testing" + "time" +) + +// loopbackPair holds both sides of an in-memory loopback for run tests. +type loopbackPair struct { + // client (A) side — passed to run() + ssA *StreamSocket + cliA *Client + // server (B) side — scripted in tests + ssB *StreamSocket + cliB *Client +} + +// newLoopbackPair builds a cross-linked loopback and returns both sides. +func newLoopbackPair(t *testing.T) loopbackPair { + t.Helper() + + ta := &pipeTransport{in: make(chan Packet, 64)} + tb := &pipeTransport{in: make(chan Packet, 64)} + ta.peer = tb + tb.peer = ta + + cliA := NewClient(ta, "/wp-cli") + cliB := NewClient(tb, "/wp-cli") + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + go cliA.readLoop(ctx) + go cliB.readLoop(ctx) + + ssA := NewStreamSocket(ctx, cliA) + ssB := NewStreamSocket(ctx, cliB) + + return loopbackPair{ssA: ssA, cliA: cliA, ssB: ssB, cliB: cliB} +} + +// script is a function that scripts the "server" (B) side when it receives a "cmd" event. +// args are the decoded arguments: [data, stdinStream, stdoutStream]. +type script func(ctx context.Context, args []any, cliB *Client, ssB *StreamSocket) + +// runWithScript runs run() on the A side while B executes the given script. +// Returns the Result and the captured stdout (combined with opts.Stdout). +func runWithScript(t *testing.T, s script) (Result, string) { + t.Helper() + t.Setenv("NO_COLOR", "1") + + pair := newLoopbackPair(t) + + var buf bytes.Buffer + opts := Options{ + Stdin: strings.NewReader(""), // empty stdin + Stdout: &buf, + Stderr: &buf, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + + // Script the server side: register a "cmd" handler on ssB. + // The handler will be called in its own goroutine (per StreamSocket.On dispatch). + pair.ssB.On("cmd", func(args []any, ackID *int) { + s(ctx, args, pair.cliB, pair.ssB) + }) + + // Run the inner function with the A-side client + StreamSocket. + var offset atomic.Int64 + resCh := make(chan Result, 1) + errCh := make(chan error, 1) + go func() { + res, err := run(ctx, opts, pair.cliA, pair.ssA, &offset, 0) + if err != nil { + errCh <- err + return + } + resCh <- res + }() + + select { + case res := <-resCh: + return res, buf.String() + case err := <-errCh: + t.Fatalf("run() returned error: %v", err) + return Result{}, "" + case <-time.After(4 * time.Second): + t.Fatal("timeout waiting for run() to complete") + return Result{}, "" + } +} + +// scriptExit returns a script that emits an exit event with the given code and message. +func scriptExit(code int, message string) script { + return func(ctx context.Context, args []any, cliB *Client, ssB *StreamSocket) { + _ = cliB.Emit(ctx, "exit", []any{map[string]any{ + "exitCode": float64(code), + "message": message, + }}, nil) + } +} + +// scriptCancel returns a script that emits a cancel event with the given message. +func scriptCancel(message string) script { + return func(ctx context.Context, args []any, cliB *Client, ssB *StreamSocket) { + _ = cliB.Emit(ctx, "cancel", []any{message}, nil) + } +} + +// scriptError returns a script that emits an error event with the given message. +func scriptError(message string) script { + return func(ctx context.Context, args []any, cliB *Client, ssB *StreamSocket) { + _ = cliB.Emit(ctx, "error", []any{message}, nil) + // After error we still need to signal exit so run() terminates. + _ = cliB.Emit(ctx, "exit", []any{map[string]any{ + "exitCode": float64(1), + }}, nil) + } +} + +// scriptStdout returns a script that writes data to the stdout stream (args[2]), +// closes it, then emits exit with the given code. +func scriptStdout(data string, code int) script { + return func(ctx context.Context, args []any, cliB *Client, ssB *StreamSocket) { + // args: [data(map), stdinStream(*IOStream), stdoutStream(*IOStream)] + if len(args) < 3 { + return + } + stdoutStream, ok := args[2].(*IOStream) + if !ok { + return + } + // Write data then close the stdout stream. + _, _ = stdoutStream.Write([]byte(data)) + _ = stdoutStream.Close() + // Emit exit to be deterministic. + _ = cliB.Emit(ctx, "exit", []any{map[string]any{ + "exitCode": float64(code), + }}, nil) + } +} + +func TestRunExitEvent(t *testing.T) { + res, out := runWithScript(t, scriptExit(5, "done")) + if res.ExitCode != 5 { + t.Errorf("exit = %d, want 5", res.ExitCode) + } + if !strings.Contains(out, "done") { + t.Errorf("message not printed: %q", out) + } +} + +func TestRunCancelEvent(t *testing.T) { + res, out := runWithScript(t, scriptCancel("nope")) + if res.ExitCode != 1 { + t.Errorf("exit = %d, want 1", res.ExitCode) + } + if !strings.Contains(out, "Cancel received from server: nope") { + t.Errorf("out = %q", out) + } +} + +func TestRunRateLimitError(t *testing.T) { + _, out := runWithScript(t, scriptError("Rate limit exceeded")) + if !strings.Contains(out, "Rate limit exceeded: Please wait a moment and try again.") { + t.Errorf("out = %q", out) + } +} + +func TestRunStdoutStreamed(t *testing.T) { + res, out := runWithScript(t, scriptStdout("line1\nline2\n", 0)) + if res.ExitCode != 0 { + t.Errorf("exit = %d", res.ExitCode) + } + if !strings.Contains(out, "line1") || !strings.Contains(out, "line2") { + t.Errorf("stdout not streamed: %q", out) + } +} diff --git a/internal/wpstream/socketio.go b/internal/wpstream/socketio.go new file mode 100644 index 000000000..984ab8413 --- /dev/null +++ b/internal/wpstream/socketio.go @@ -0,0 +1,435 @@ +package wpstream + +import ( + "bytes" + "context" + "encoding/json/v2" + "fmt" + "strconv" + "sync" + "sync/atomic" +) + +// Socket.IO v4 packet types (socket.io-parser, protocol 5). +const ( + sioConnect = 0 + sioDisconnect = 1 + sioEvent = 2 + sioAck = 3 + sioConnectError = 4 + sioBinaryEvent = 5 + sioBinaryAck = 6 +) + +// binaryArg wraps a []byte so the encoder emits it as a socket.io binary +// attachment (placeholder + separate frame) rather than JSON. +type binaryArg []byte + +// sioPacket is a decoded/decodable Socket.IO packet. +type sioPacket struct { + Type int + Nsp string + ID *int // ack id + Data []any // event name + args (decoded; binary args are []byte) + attachments int // BINARY_* only +} + +func intPtr(i int) *int { return &i } + +// encodePacket renders a packet to one text frame plus N binary frames. +func encodePacket(p sioPacket) ([]Packet, error) { + typ := p.Type + var attachments [][]byte + data := p.Data + + if hasBinary(data) { + switch typ { + case sioEvent: + typ = sioBinaryEvent + case sioAck: + typ = sioBinaryAck + } + deconstructed, bufs := deconstruct(data) + data, _ = deconstructed.([]any) + attachments = bufs + } + + var b bytes.Buffer + b.WriteString(strconv.Itoa(typ)) + if typ == sioBinaryEvent || typ == sioBinaryAck { + b.WriteString(strconv.Itoa(len(attachments))) + b.WriteByte('-') + } + if p.Nsp != "" && p.Nsp != "/" { + b.WriteString(p.Nsp) + b.WriteByte(',') + } + if p.ID != nil { + b.WriteString(strconv.Itoa(*p.ID)) + } + if data != nil { + j, err := json.Marshal(data) + if err != nil { + return nil, err + } + b.Write(j) + } + + frames := []Packet{{Type: eioMessage, Data: b.Bytes()}} + for _, a := range attachments { + frames = append(frames, Packet{Type: eioMessage, Data: a, Binary: true}) + } + return frames, nil +} + +func hasBinary(v any) bool { + switch t := v.(type) { + case binaryArg: + return true + case []any: + for _, e := range t { + if hasBinary(e) { + return true + } + } + case map[string]any: + for _, e := range t { + if hasBinary(e) { + return true + } + } + } + return false +} + +// deconstruct walks data, replacing each binaryArg with a placeholder and +// collecting the raw bytes (socket.io-parser binary.js). +func deconstruct(v any) (any, [][]byte) { + var bufs [][]byte + var walk func(any) any + walk = func(x any) any { + switch t := x.(type) { + case binaryArg: + ph := map[string]any{"_placeholder": true, "num": len(bufs)} + bufs = append(bufs, []byte(t)) + return ph + case []any: + out := make([]any, len(t)) + for i, e := range t { + out[i] = walk(e) + } + return out + case map[string]any: + out := make(map[string]any, len(t)) + for k, e := range t { + out[k] = walk(e) + } + return out + default: + return x + } + } + return walk(v), bufs +} + +// sioDecoder reassembles packets, buffering binary attachments. +type sioDecoder struct { + pending *sioPacket + placeholds int + bufs [][]byte +} + +func newSioDecoder() *sioDecoder { return &sioDecoder{} } + +// add feeds one Engine.IO packet. Returns (packet, true) when a full Socket.IO +// packet is assembled, or (_, false) while awaiting binary attachments. +func (d *sioDecoder) add(pkt Packet) (sioPacket, bool, error) { + if pkt.Binary { + if d.pending == nil { + return sioPacket{}, false, fmt.Errorf("wpstream: unexpected binary frame") + } + d.bufs = append(d.bufs, pkt.Data) + if len(d.bufs) < d.placeholds { + return sioPacket{}, false, nil + } + p := *d.pending + p.Data = reconstruct(p.Data, d.bufs).([]any) + d.pending, d.placeholds, d.bufs = nil, 0, nil + return p, true, nil + } + + p, attachments, err := decodeString(pkt.Data) + if err != nil { + return sioPacket{}, false, err + } + if attachments == 0 { + return p, true, nil + } + d.pending, d.placeholds, d.bufs = &p, attachments, nil + return sioPacket{}, false, nil +} + +// decodeString parses the text portion. Returns the packet plus the number of +// expected binary attachments. +func decodeString(b []byte) (sioPacket, int, error) { + if len(b) == 0 { + return sioPacket{}, 0, fmt.Errorf("wpstream: empty packet") + } + i := 0 + typ := int(b[i] - '0') + i++ + attachments := 0 + if typ == sioBinaryEvent || typ == sioBinaryAck { + j := i + for j < len(b) && b[j] != '-' { + j++ + } + n, _ := strconv.Atoi(string(b[i:j])) + attachments = n + i = j + 1 + } + nsp := "/" + if i < len(b) && b[i] == '/' { + j := i + for j < len(b) && b[j] != ',' { + j++ + } + nsp = string(b[i:j]) + if j < len(b) { + j++ // skip comma + } + i = j + } + var idp *int + if i < len(b) && b[i] >= '0' && b[i] <= '9' { + j := i + for j < len(b) && b[j] >= '0' && b[j] <= '9' { + j++ + } + id, _ := strconv.Atoi(string(b[i:j])) + idp = &id + i = j + } + p := sioPacket{Type: typ, Nsp: nsp, ID: idp, attachments: attachments} + if i < len(b) { + rest := b[i:] + if len(rest) > 0 && rest[0] == '[' { + // EVENT / ACK: JSON array of [eventName, ...args] + var data []any + if err := json.Unmarshal(rest, &data); err != nil { + return sioPacket{}, 0, fmt.Errorf("wpstream: decode data: %w", err) + } + p.Data = data + } else { + // CONNECT / CONNECT_ERROR: JSON object payload, store as single element. + var obj any + if err := json.Unmarshal(rest, &obj); err != nil { + return sioPacket{}, 0, fmt.Errorf("wpstream: decode data: %w", err) + } + p.Data = []any{obj} + } + } + return p, attachments, nil +} + +// reconstruct replaces {"_placeholder":true,"num":N} markers with bufs[N]. +func reconstruct(v any, bufs [][]byte) any { + switch t := v.(type) { + case map[string]any: + if ph, _ := t["_placeholder"].(bool); ph { + if num, ok := t["num"].(float64); ok && int(num) < len(bufs) { + return bufs[int(num)] + } + } + for k, e := range t { + t[k] = reconstruct(e, bufs) + } + return t + case []any: + for i, e := range t { + t[i] = reconstruct(e, bufs) + } + return t + default: + return v + } +} + +// transport is the Engine.IO interface that Client writes to and reads from. +// *Engine satisfies it; tests may substitute an in-memory loopback (Task 3). +type transport interface { + Read(ctx context.Context) (Packet, error) + WriteMessage(ctx context.Context, payload []byte) error + WriteBinary(ctx context.Context, data []byte) error +} + +// Client is a Socket.IO v4 namespace client over a transport. +type Client struct { + eng transport + nsp string + dec *sioDecoder + + mu sync.Mutex + handlers map[string][]func(args []any) + rawHandlers map[string][]func(args []any, ackID *int) + ackID atomic.Int64 + acks map[int]func(args []any) + connected chan struct{} + connErr chan error +} + +// NewClient creates a Socket.IO namespace client over the given transport. +func NewClient(eng transport, nsp string) *Client { + return &Client{ + eng: eng, nsp: nsp, dec: newSioDecoder(), + handlers: map[string][]func([]any){}, + rawHandlers: map[string][]func([]any, *int){}, + acks: map[int]func([]any){}, + connected: make(chan struct{}), connErr: make(chan error, 1), + } +} + +// On registers a handler for the named event. +func (c *Client) On(event string, h func(args []any)) { + c.mu.Lock() + c.handlers[event] = append(c.handlers[event], h) + c.mu.Unlock() +} + +// OnRaw registers a handler for the named event that also receives the ack id. +// Raw handlers fire before plain On handlers. The iostream layer uses this to +// send ACK replies for $stream-write events. +func (c *Client) OnRaw(event string, h func(args []any, ackID *int)) { + c.mu.Lock() + c.rawHandlers[event] = append(c.rawHandlers[event], h) + c.mu.Unlock() +} + +// Emit sends an event to the server. If ack is non-nil the packet carries an +// ack id and ack will be called when the server replies. +func (c *Client) Emit(ctx context.Context, event string, args []any, ack func([]any)) error { + p := sioPacket{Type: sioEvent, Nsp: c.nsp, Data: append([]any{event}, args...)} + if ack != nil { + id := int(c.ackID.Add(1)) + p.ID = &id + c.mu.Lock() + c.acks[id] = ack + c.mu.Unlock() + } + return c.sendPacket(ctx, p) +} + +// ackReply sends a Socket.IO ACK for a previously received event id. +func (c *Client) ackReply(ctx context.Context, id int, args []any) error { + return c.sendPacket(ctx, sioPacket{Type: sioAck, Nsp: c.nsp, ID: &id, Data: args}) +} + +func (c *Client) sendPacket(ctx context.Context, p sioPacket) error { + frames, err := encodePacket(p) + if err != nil { + return err + } + for _, f := range frames { + if f.Binary { + if err := c.eng.WriteBinary(ctx, f.Data); err != nil { + return err + } + continue + } + if err := c.eng.WriteMessage(ctx, f.Data); err != nil { + return err + } + } + return nil +} + +// Connect sends the namespace CONNECT packet and waits for the server CONNECT +// ack, then starts the read loop in a background goroutine. +func (c *Client) Connect(ctx context.Context) error { + if err := c.sendPacket(ctx, sioPacket{Type: sioConnect, Nsp: c.nsp}); err != nil { + return err + } + go c.readLoop(ctx) + select { + case <-c.connected: + return nil + case err := <-c.connErr: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +func (c *Client) readLoop(ctx context.Context) { + for { + pkt, err := c.eng.Read(ctx) + if err != nil { + c.dispatch("disconnect", []any{err.Error()}) + return + } + p, complete, derr := c.dec.add(pkt) + if derr != nil || !complete { + continue + } + c.handlePacket(ctx, p) + } +} + +func (c *Client) handlePacket(ctx context.Context, p sioPacket) { + switch p.Type { + case sioConnect: + select { + case <-c.connected: + default: + close(c.connected) + } + case sioConnectError: + select { + case c.connErr <- fmt.Errorf("wpstream: connect_error: %v", p.Data): + default: + } + case sioEvent, sioBinaryEvent: + if len(p.Data) == 0 { + return + } + event, _ := p.Data[0].(string) + args := p.Data[1:] + c.dispatchWithAck(ctx, event, args, p.ID) + case sioAck, sioBinaryAck: + if p.ID == nil { + return + } + c.mu.Lock() + ack := c.acks[*p.ID] + delete(c.acks, *p.ID) + c.mu.Unlock() + if ack != nil { + ack(p.Data) + } + case sioDisconnect: + c.dispatch("disconnect", nil) + } +} + +func (c *Client) dispatch(event string, args []any) { + c.mu.Lock() + hs := append([]func([]any){}, c.handlers[event]...) + c.mu.Unlock() + for _, h := range hs { + h(args) + } +} + +// dispatchWithAck delivers an event, invoking raw handlers (with the ack id) +// before plain handlers. The iostream layer registers $stream-write via OnRaw +// and sends the ack itself; nothing is auto-acked here. +func (c *Client) dispatchWithAck(ctx context.Context, event string, args []any, id *int) { + c.mu.Lock() + rhs := append([]func([]any, *int){}, c.rawHandlers[event]...) + c.mu.Unlock() + for _, h := range rhs { + h(args, id) + } + c.dispatch(event, args) + _ = ctx +} diff --git a/internal/wpstream/socketio_test.go b/internal/wpstream/socketio_test.go new file mode 100644 index 000000000..e896a3186 --- /dev/null +++ b/internal/wpstream/socketio_test.go @@ -0,0 +1,402 @@ +package wpstream + +import ( + "bytes" + "context" + "encoding/json/v2" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/coder/websocket" +) + +// ── codec tests ────────────────────────────────────────────────────────────── + +func TestEncodeStringEvent(t *testing.T) { + p := sioPacket{Type: sioEvent, Nsp: "/wp-cli", Data: []any{"x", map[string]any{"a": float64(1)}}} + frames, err := encodePacket(p) + if err != nil { + t.Fatal(err) + } + if len(frames) != 1 || frames[0].Binary { + t.Fatalf("frames = %+v", frames) + } + if got := string(frames[0].Data); got != `2/wp-cli,["x",{"a":1}]` { + t.Errorf("encoded = %q", got) + } +} + +func TestEncodeEventWithAckID(t *testing.T) { + p := sioPacket{Type: sioEvent, Nsp: "/wp-cli", ID: intPtr(7), Data: []any{"ev"}} + frames, _ := encodePacket(p) + if got := string(frames[0].Data); got != `2/wp-cli,7["ev"]` { + t.Errorf("encoded = %q", got) + } +} + +func TestEncodeBinaryEvent(t *testing.T) { + chunk := []byte{0xde, 0xad} + p := sioPacket{Type: sioEvent, Nsp: "/wp-cli", ID: intPtr(3), + Data: []any{"$stream-write", "sid", binaryArg(chunk), "buffer"}} + frames, err := encodePacket(p) + if err != nil { + t.Fatal(err) + } + if len(frames) != 2 { + t.Fatalf("want 2 frames, got %d", len(frames)) + } + const prefix = `51-/wp-cli,3` + if !bytes.HasPrefix(frames[0].Data, []byte(prefix)) { + t.Fatalf("header = %q, want prefix %q", frames[0].Data, prefix) + } + var payload []any + if err := json.Unmarshal(frames[0].Data[len(prefix):], &payload); err != nil { + t.Fatalf("decode header payload: %v", err) + } + if len(payload) != 4 || payload[0] != "$stream-write" || payload[1] != "sid" || payload[3] != "buffer" { + t.Fatalf("header payload = %#v", payload) + } + placeholder, ok := payload[2].(map[string]any) + if !ok || len(placeholder) != 2 { + t.Fatalf("placeholder = %#v", payload[2]) + } + if marker, ok := placeholder["_placeholder"].(bool); !ok || !marker { + t.Errorf("placeholder marker = %#v", placeholder["_placeholder"]) + } + if num, ok := placeholder["num"].(float64); !ok || num != 0 { + t.Errorf("placeholder number = %#v", placeholder["num"]) + } + if !frames[1].Binary || !bytes.Equal(frames[1].Data, chunk) { + t.Errorf("attachment frame = %+v", frames[1]) + } +} + +func TestDecodeStringEvent(t *testing.T) { + d := newSioDecoder() + p, complete, err := d.add(Packet{Type: '4', Data: []byte(`2/wp-cli,["exit",{"exitCode":0}]`)}) + if err != nil || !complete { + t.Fatalf("complete=%v err=%v", complete, err) + } + if p.Type != sioEvent || p.Nsp != "/wp-cli" { + t.Errorf("packet = %+v", p) + } + if p.Data[0] != "exit" { + t.Errorf("event = %v", p.Data[0]) + } +} + +func TestDecodeBinaryEventReassembly(t *testing.T) { + d := newSioDecoder() + p, complete, err := d.add(Packet{Type: '4', + Data: []byte(`51-/wp-cli,["$stream-write","sid",{"_placeholder":true,"num":0},"buffer"]`)}) + if err != nil { + t.Fatal(err) + } + if complete { + t.Fatal("must wait for the binary attachment") + } + p, complete, err = d.add(Packet{Binary: true, Data: []byte{0x01, 0x02}}) + if err != nil || !complete { + t.Fatalf("complete=%v err=%v", complete, err) + } + if p.Data[0] != "$stream-write" { + t.Errorf("event = %v", p.Data[0]) + } + got, ok := p.Data[2].([]byte) + if !ok || !bytes.Equal(got, []byte{0x01, 0x02}) { + t.Errorf("reassembled attachment = %v (%T)", p.Data[2], p.Data[2]) + } +} + +// ── client tests ───────────────────────────────────────────────────────────── + +// fakeSocketIOServer builds a test HTTP server that: +// 1. answers the EIO4 polling handshake +// 2. accepts the websocket upgrade + 2probe/5 dance +// 3. runs fn(ctx, wsConn) for the server-side socket.io logic +func fakeSocketIOServer(t *testing.T, fn func(ctx context.Context, c *websocket.Conn)) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/socket.io/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("transport") == "polling" { + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") + _, _ = w.Write([]byte(`0{"sid":"abc","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}`)) + return + } + c, err := websocket.Accept(w, r, nil) + if err != nil { + t.Logf("ws accept: %v", err) + return + } + defer c.Close(websocket.StatusNormalClosure, "") + ctx := r.Context() + // EIO4 probe dance + _, probe, _ := c.Read(ctx) + if string(probe) != "2probe" { + t.Errorf("probe = %q", probe) + } + _ = c.Write(ctx, websocket.MessageText, []byte("3probe")) + _, up, _ := c.Read(ctx) + if string(up) != "5" { + t.Errorf("upgrade = %q", up) + } + fn(ctx, c) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// sendSIO writes a text Socket.IO packet wrapped in EIO "4" envelope. +func sendSIO(ctx context.Context, c *websocket.Conn, payload string) error { + return c.Write(ctx, websocket.MessageText, []byte("4"+payload)) +} + +// readSIOPacket reads one EIO text frame and strips the leading "4". +func readSIOPacket(ctx context.Context, c *websocket.Conn) (string, error) { + _, b, err := c.Read(ctx) + if err != nil { + return "", err + } + if len(b) == 0 || b[0] != '4' { + return "", fmt.Errorf("expected EIO message frame, got %q", b) + } + return string(b[1:]), nil +} + +func TestClientConnectAndEvent(t *testing.T) { + exitFired := make(chan float64, 1) + + srv := fakeSocketIOServer(t, func(ctx context.Context, c *websocket.Conn) { + // Read client CONNECT for /wp-cli + raw, err := readSIOPacket(ctx, c) + if err != nil { + t.Errorf("reading client CONNECT: %v", err) + return + } + if raw != "0/wp-cli," { + t.Errorf("client CONNECT = %q, want %q", raw, "0/wp-cli,") + } + + // Send CONNECT ack + _ = sendSIO(ctx, c, `0/wp-cli,{"sid":"s1"}`) + + // Send EVENT: exit with exitCode 0 + _ = sendSIO(ctx, c, `2/wp-cli,["exit",{"exitCode":0}]`) + + // Hold the connection open briefly so the client can process. + time.Sleep(50 * time.Millisecond) + }) + + eng, err := Dial(context.Background(), DialOptions{BaseURL: srv.URL}) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer eng.Close() + + cl := NewClient(eng, "/wp-cli") + cl.On("exit", func(args []any) { + if m, ok := args[0].(map[string]any); ok { + if code, ok := m["exitCode"].(float64); ok { + exitFired <- code + } + } + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if err := cl.Connect(ctx); err != nil { + t.Fatalf("Connect: %v", err) + } + + select { + case code := <-exitFired: + if code != 0 { + t.Errorf("exitCode = %v, want 0", code) + } + case <-time.After(time.Second): + t.Fatal("timeout: exit handler never fired") + } +} + +func TestClientOnRawReceivesAckID(t *testing.T) { + rawFired := make(chan *int, 1) + + srv := fakeSocketIOServer(t, func(ctx context.Context, c *websocket.Conn) { + // Read client CONNECT + _, _ = readSIOPacket(ctx, c) + + // Send CONNECT ack + _ = sendSIO(ctx, c, `0/wp-cli,{"sid":"s2"}`) + + // Send EVENT with ack id 42 + _ = sendSIO(ctx, c, `2/wp-cli,42["$stream-write","somearg"]`) + + time.Sleep(100 * time.Millisecond) + }) + + eng, err := Dial(context.Background(), DialOptions{BaseURL: srv.URL}) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer eng.Close() + + cl := NewClient(eng, "/wp-cli") + cl.OnRaw("$stream-write", func(args []any, ackID *int) { + rawFired <- ackID + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if err := cl.Connect(ctx); err != nil { + t.Fatalf("Connect: %v", err) + } + + select { + case id := <-rawFired: + if id == nil { + t.Fatal("ackID is nil, expected 42") + } + if *id != 42 { + t.Errorf("ackID = %d, want 42", *id) + } + case <-time.After(time.Second): + t.Fatal("timeout: OnRaw handler never fired") + } +} + +// TestClientEmitAck verifies that Emit with an ack callback receives the reply. +func TestClientEmitAck(t *testing.T) { + ackReceived := make(chan []any, 1) + + srv := fakeSocketIOServer(t, func(ctx context.Context, c *websocket.Conn) { + // Read client CONNECT + _, _ = readSIOPacket(ctx, c) + + // Send CONNECT ack + _ = sendSIO(ctx, c, `0/wp-cli,{"sid":"s3"}`) + + // Read the Emit frame + raw, err := readSIOPacket(ctx, c) + if err != nil { + t.Errorf("reading emit: %v", err) + return + } + // Decode the ack id from the packet (e.g. "2/wp-cli,1["ping"]") + // Simple approach: just parse the ack from it. + // For test purposes decode enough to get the ack id. + d := newSioDecoder() + pkt, complete, derr := d.add(Packet{Type: eioMessage, Data: []byte(raw)}) + if derr != nil || !complete { + t.Errorf("decode emit: err=%v complete=%v", derr, complete) + return + } + if pkt.ID == nil { + t.Error("no ack id in emitted packet") + return + } + // Send ACK back: "3/wp-cli,["pong"]" + ackPkt := fmt.Sprintf(`3/wp-cli,%d["pong"]`, *pkt.ID) + _ = sendSIO(ctx, c, ackPkt) + + time.Sleep(100 * time.Millisecond) + }) + + eng, err := Dial(context.Background(), DialOptions{BaseURL: srv.URL}) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer eng.Close() + + cl := NewClient(eng, "/wp-cli") + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if err := cl.Connect(ctx); err != nil { + t.Fatalf("Connect: %v", err) + } + + if err := cl.Emit(ctx, "ping", nil, func(args []any) { + ackReceived <- args + }); err != nil { + t.Fatalf("Emit: %v", err) + } + + select { + case args := <-ackReceived: + if len(args) == 0 || args[0] != "pong" { + t.Errorf("ack args = %v, want [pong]", args) + } + case <-time.After(time.Second): + t.Fatal("timeout: ack never received") + } +} + +// TestClientAckReply verifies that ackReply sends a proper ACK packet. +func TestClientAckReply(t *testing.T) { + ackRaw := make(chan string, 1) + + srv := fakeSocketIOServer(t, func(ctx context.Context, c *websocket.Conn) { + // Read client CONNECT + _, _ = readSIOPacket(ctx, c) + _ = sendSIO(ctx, c, `0/wp-cli,{"sid":"s4"}`) + + // Send an event with ack id 99 + _ = sendSIO(ctx, c, `2/wp-cli,99["greet","hello"]`) + + // Read the ACK reply + raw, err := readSIOPacket(ctx, c) + if err != nil { + t.Logf("reading ack reply: %v", err) + return + } + ackRaw <- raw + }) + + eng, err := Dial(context.Background(), DialOptions{BaseURL: srv.URL}) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer eng.Close() + + cl := NewClient(eng, "/wp-cli") + cl.OnRaw("greet", func(args []any, ackID *int) { + if ackID != nil { + ctx := context.Background() + _ = cl.ackReply(ctx, *ackID, []any{"world"}) + } + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if err := cl.Connect(ctx); err != nil { + t.Fatalf("Connect: %v", err) + } + + select { + case raw := <-ackRaw: + var data []any + // Parse the reply: "3/wp-cli,99["world"]" + d := newSioDecoder() + pkt, _, _ := d.add(Packet{Type: eioMessage, Data: []byte(raw)}) + if pkt.Type != sioAck { + t.Errorf("reply type = %d, want sioAck(%d)", pkt.Type, sioAck) + } + if pkt.ID == nil || *pkt.ID != 99 { + t.Errorf("reply ack id = %v, want 99", pkt.ID) + } + data = pkt.Data + if len(data) == 0 || data[0] != "world" { + t.Errorf("reply data = %v", data) + } + case <-time.After(time.Second): + t.Fatal("timeout: ack reply never received") + } +} diff --git a/internal/wpstream/testdata/fixture-server.js b/internal/wpstream/testdata/fixture-server.js new file mode 100644 index 000000000..3e1669dd4 --- /dev/null +++ b/internal/wpstream/testdata/fixture-server.js @@ -0,0 +1,54 @@ +// Minimal socket.io v4 server implementing a fake /wp-cli namespace for the +// Go wpstream e2e tests. Reads the cmd payload, streams scripted stdout via +// socket.io-stream, optionally kills the connection mid-stream (offset resume), +// then emits exit. Configured via env: +// PORT (required) — listen port +// SCRIPT_STDOUT — bytes to stream to stdout +// EXIT_CODE — exit code to emit (default 0) +// EXIT_MESSAGE — optional exit message +// KILL_AFTER — if set, destroy the connection after N stdout bytes +// KILL_TIMES — how many times to kill (default: 1 when KILL_AFTER set, +// else 0); use KILL_TIMES=2 to force two reconnects +const http = require('http'); +const { Server } = require('socket.io'); +const ss = require('socket.io-stream'); + +const server = http.createServer(); +const io = new Server(server, { /* default EIO4 */ }); + +const STDOUT = Buffer.from(process.env.SCRIPT_STDOUT || ''); +const EXIT_CODE = Number(process.env.EXIT_CODE || 0); +const EXIT_MESSAGE = process.env.EXIT_MESSAGE || ''; +const KILL_AFTER = process.env.KILL_AFTER ? Number(process.env.KILL_AFTER) : -1; + +// Default KILL_TIMES to 1 when KILL_AFTER is set and KILL_TIMES is not +// explicitly provided, so the existing single-kill test is unaffected. +let killsRemaining = KILL_AFTER >= 0 + ? (process.env.KILL_TIMES !== undefined ? Number(process.env.KILL_TIMES) : 1) + : 0; + +io.of('/wp-cli').on('connection', socket => { + ss(socket).on('cmd', (data, stdinStream, stdoutStream) => { + const offset = data.offset || 0; + let slice = STDOUT.slice(offset); + + if (KILL_AFTER >= 0 && killsRemaining > 0 && slice.length > KILL_AFTER) { + killsRemaining--; + stdoutStream.write(slice.slice(0, KILL_AFTER)); + // Drop the connection mid-stream to force a Go-side reconnect+resume. + setImmediate(() => socket.client.conn.close()); + return; + } + + stdoutStream.end(slice); + // Drain any stdin the client sends (echo not required for these tests). + stdinStream.resume(); + stdoutStream.on('end', () => { + socket.emit('exit', { exitCode: EXIT_CODE, message: EXIT_MESSAGE }); + }); + }); +}); + +server.listen(Number(process.env.PORT), '127.0.0.1', () => { + process.stdout.write('LISTENING\n'); // handshake for the Go test +}); From 3319bd7c33168758a54710742d484974c5cab480 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 20 Aug 2026 18:36:54 -0500 Subject: [PATCH 14/32] feat(devenv): container model and Docker integration Ported from vip-cli-golang@72ef2f89 (feature/go-rewrite). --- internal/devenv/compose/labels.go | 90 +++++ internal/devenv/compose/labels_test.go | 136 +++++++ internal/devenv/compose/project.go | 88 +++++ internal/devenv/compose/project_test.go | 148 ++++++++ internal/devenv/compose/render.go | 54 +++ internal/devenv/compose/render_test.go | 90 +++++ internal/devenv/compose/services.go | 337 ++++++++++++++++++ internal/devenv/compose/services_test.go | 258 ++++++++++++++ .../devenv/compose/testdata/full.golden.yml | 265 ++++++++++++++ internal/devenv/compose/types.go | 97 +++++ internal/devenv/compose/types_test.go | 61 ++++ internal/devenv/compose/view.go | 202 +++++++++++ internal/devenv/compose/view_test.go | 120 +++++++ internal/devenv/devlog/devlog.go | 219 ++++++++++++ internal/devenv/devlog/devlog_test.go | 112 ++++++ internal/devenv/devterm/devterm.go | 65 ++++ internal/devenv/devterm/devterm_pty.go | 58 +++ internal/devenv/devterm/devterm_stub.go | 20 ++ internal/devenv/devterm/devterm_test.go | 78 ++++ internal/devenv/dockercli/capture.go | 84 +++++ internal/devenv/dockercli/capture_test.go | 33 ++ internal/devenv/dockercli/compose.go | 30 ++ internal/devenv/dockercli/compose_test.go | 35 ++ internal/devenv/dockercli/runner.go | 214 +++++++++++ internal/devenv/dockercli/runner_test.go | 157 ++++++++ internal/devenv/dockercli/socket.go | 56 +++ internal/devenv/dockercli/socket_test.go | 52 +++ internal/devenv/e2esafety/gate_wiring_test.go | 90 +++++ internal/devenv/e2esafety/safety.go | 52 +++ internal/devenv/e2esafety/safety_test.go | 71 ++++ internal/devenv/instancedata/instancedata.go | 239 +++++++++++++ .../devenv/instancedata/instancedata_test.go | 281 +++++++++++++++ .../instancedata/testdata/legacy_keys.json | 16 + .../instancedata/testdata/unknown_keys.json | 19 + internal/devenv/paths/paths.go | 42 +++ internal/devenv/paths/paths_test.go | 39 ++ 36 files changed, 4008 insertions(+) create mode 100644 internal/devenv/compose/labels.go create mode 100644 internal/devenv/compose/labels_test.go create mode 100644 internal/devenv/compose/project.go create mode 100644 internal/devenv/compose/project_test.go create mode 100644 internal/devenv/compose/render.go create mode 100644 internal/devenv/compose/render_test.go create mode 100644 internal/devenv/compose/services.go create mode 100644 internal/devenv/compose/services_test.go create mode 100644 internal/devenv/compose/testdata/full.golden.yml create mode 100644 internal/devenv/compose/types.go create mode 100644 internal/devenv/compose/types_test.go create mode 100644 internal/devenv/compose/view.go create mode 100644 internal/devenv/compose/view_test.go create mode 100644 internal/devenv/devlog/devlog.go create mode 100644 internal/devenv/devlog/devlog_test.go create mode 100644 internal/devenv/devterm/devterm.go create mode 100644 internal/devenv/devterm/devterm_pty.go create mode 100644 internal/devenv/devterm/devterm_stub.go create mode 100644 internal/devenv/devterm/devterm_test.go create mode 100644 internal/devenv/dockercli/capture.go create mode 100644 internal/devenv/dockercli/capture_test.go create mode 100644 internal/devenv/dockercli/compose.go create mode 100644 internal/devenv/dockercli/compose_test.go create mode 100644 internal/devenv/dockercli/runner.go create mode 100644 internal/devenv/dockercli/runner_test.go create mode 100644 internal/devenv/dockercli/socket.go create mode 100644 internal/devenv/dockercli/socket_test.go create mode 100644 internal/devenv/e2esafety/gate_wiring_test.go create mode 100644 internal/devenv/e2esafety/safety.go create mode 100644 internal/devenv/e2esafety/safety_test.go create mode 100644 internal/devenv/instancedata/instancedata.go create mode 100644 internal/devenv/instancedata/instancedata_test.go create mode 100644 internal/devenv/instancedata/testdata/legacy_keys.json create mode 100644 internal/devenv/instancedata/testdata/unknown_keys.json create mode 100644 internal/devenv/paths/paths.go create mode 100644 internal/devenv/paths/paths_test.go diff --git a/internal/devenv/compose/labels.go b/internal/devenv/compose/labels.go new file mode 100644 index 000000000..998b2a225 --- /dev/null +++ b/internal/devenv/compose/labels.go @@ -0,0 +1,90 @@ +package compose + +import ( + "fmt" + "strings" +) + +// hostRule builds a Traefik HostRegexp rule for a hostname, converting a "*" +// wildcard to the [a-z0-9-]+ class (ports lando-proxy/lib/utils.js getRule). +func hostRule(host string) string { + re := strings.ReplaceAll(host, "*", "[a-z0-9-]+") + return fmt.Sprintf("HostRegexp(`%s`)", re) +} + +// routerLabels emits an http router + a tls (secured) router for one routed +// hostname pattern on a given service port, all prefixed by id. +func routerLabels(id, rule string, port int, labels map[string]string) { + labels[fmt.Sprintf("traefik.http.routers.%s.entrypoints", id)] = "http" + labels[fmt.Sprintf("traefik.http.routers.%s.rule", id)] = rule + labels[fmt.Sprintf("traefik.http.routers.%s.service", id)] = id + "-service" + labels[fmt.Sprintf("traefik.http.services.%s-service.loadbalancer.server.port", id)] = fmt.Sprintf("%d", port) + + sec := id + "-secured" + labels[fmt.Sprintf("traefik.http.routers.%s.entrypoints", sec)] = "https" + labels[fmt.Sprintf("traefik.http.routers.%s.rule", sec)] = rule + labels[fmt.Sprintf("traefik.http.routers.%s.tls", sec)] = "true" + labels[fmt.Sprintf("traefik.http.routers.%s.service", sec)] = sec + "-service" + // Build the secured service key from id (not sec) so it resolves to + // "-secured-service" — matching the router .service pointer above and + // Node's ${rule.id}-secured-service (utils.js:205). Using sec here would + // yield "-secured-secured-service" and silently break TLS routing. + labels[fmt.Sprintf("traefik.http.services.%s-secured-service.loadbalancer.server.port", id)] = fmt.Sprintf("%d", port) +} + +// nginxLabels routes the front-end hostname(s) to the nginx service, adding the +// multisite wildcard host when enabled. The nginx image listens on port 80 (its +// listen directive + the EJS proxy entry, which has no explicit port → Lando's +// default 80); routing Traefik to 8080 yields a 502 since nothing listens there. +func nginxLabels(v View) map[string]string { + labels := map[string]string{"traefik.enable": "true"} + base := v.SiteSlug + "." + v.Domain + routerLabels("nginx-"+v.SiteSlug, hostRule(base), 80, labels) + if v.MultisiteEnabled { + routerLabels("nginx-"+v.SiteSlug+"-wild", hostRule("*."+base), 80, labels) + } + return labels +} + +// phpMyAdminLabels routes -pma. to phpmyadmin (port 80). +func phpMyAdminLabels(v View) map[string]string { + labels := map[string]string{"traefik.enable": "true"} + routerLabels("pma-"+v.SiteSlug, hostRule(v.SiteSlug+"-pma."+v.Domain), 80, labels) + return labels +} + +// mailpitLabels routes -mailpit. to mailpit (port 8025). +func mailpitLabels(v View) map[string]string { + labels := map[string]string{"traefik.enable": "true"} + routerLabels("mailpit-"+v.SiteSlug, hostRule(v.SiteSlug+"-mailpit."+v.Domain), 8025, labels) + return labels +} + +// CertSANs returns the hostnames needing a TLS cert for this environment — the +// set that gets secured (https/tls) Traefik routers above. These SANs are the +// single source of truth for the env's edge certificate: the proxy package +// generates one leaf cert covering them centrally (proxy.EnsureCert), because +// the traefik_openssl image runs no in-service cert machinery (Task 1 findings). +// Consequently the app services carry no cert env or certs volume — TLS +// terminates at the Traefik edge using the file-provider cert built from this list. +func CertSANs(v View) []string { + // Lead with a base-domain wildcard (like Lando's *.lndo.site) so the cert + // covers this env's host AND any one-label subdomain of the base domain — + // every ., the -pma/-mailpit hosts, and sibling envs — without + // per-host SANs. The explicit hosts below remain for clarity/exactness, and + // the deeper multisite wildcard (two labels) is still added separately. + sans := []string{ + "*." + v.Domain, + v.SiteSlug + "." + v.Domain, + } + if v.MultisiteEnabled { + sans = append(sans, "*."+v.SiteSlug+"."+v.Domain) + } + if v.PHPMyAdmin { + sans = append(sans, v.SiteSlug+"-pma."+v.Domain) + } + if v.Mailpit { + sans = append(sans, v.SiteSlug+"-mailpit."+v.Domain) + } + return sans +} diff --git a/internal/devenv/compose/labels_test.go b/internal/devenv/compose/labels_test.go new file mode 100644 index 000000000..d6b3128b3 --- /dev/null +++ b/internal/devenv/compose/labels_test.go @@ -0,0 +1,136 @@ +package compose + +import ( + "strings" + "testing" +) + +func TestNginxLabelsSingleSite(t *testing.T) { + v := baseView() // single site + labels := nginxLabels(v) + host := "example.vipdev.lndo.site" + if !labelValueContains(labels, "rule", host) { + t.Fatalf("no router rule for %s in %v", host, labels) + } + if !anyLabelKeyContains(labels, "-secured") || !anyLabelKeyContains(labels, ".tls") { + t.Fatalf("expected an https/tls router: %v", labels) + } + // nginx listens on 80 (not 8080); routing Traefik elsewhere yields a 502. + if !labelValueContains(labels, "loadbalancer.server.port", "80") { + t.Fatalf("expected nginx lb port 80: %v", labels) + } +} + +func TestNginxLabelsMultisiteWildcard(t *testing.T) { + v := baseView() + v.MultisiteEnabled = true + labels := nginxLabels(v) + if !anyLabelValueContains(labels, "[a-z0-9-]+") { + t.Fatalf("expected wildcard regex in multisite rule: %v", labels) + } +} + +func TestCertSANsIncludeEnabledServices(t *testing.T) { + v := baseView() + v.PHPMyAdmin = true + v.Mailpit = true + sans := CertSANs(v) + joined := strings.Join(sans, ",") + for _, want := range []string{"example.vipdev.lndo.site", "example-pma.vipdev.lndo.site", "example-mailpit.vipdev.lndo.site"} { + if !strings.Contains(joined, want) { + t.Fatalf("SANs missing %q: %v", want, sans) + } + } +} + +func TestCertSANsIncludesBaseDomainWildcard(t *testing.T) { + sans := CertSANs(baseView()) + found := false + for _, s := range sans { + if s == "*.vipdev.lndo.site" { + found = true + } + } + if !found { + t.Fatalf("CertSANs must include the base-domain wildcard *.vipdev.lndo.site (Lando-style subdomain cert): %v", sans) + } +} + +func TestCertSANsCoversMultisiteWildcard(t *testing.T) { + v := baseView() + v.MultisiteEnabled = true + sans := CertSANs(v) + var hasBase, hasWild bool + for _, s := range sans { + if s == "example.vipdev.lndo.site" { + hasBase = true + } + if s == "*.example.vipdev.lndo.site" { + hasWild = true + } + } + if !hasBase { + t.Fatalf("CertSANs missing base host: %v", sans) + } + if !hasWild { + t.Fatalf("CertSANs missing multisite wildcard *.example.vipdev.lndo.site (the multisite secured router needs it): %v", sans) + } +} + +// TestEveryRouterServicePointerHasDefinition is a structural invariant: every +// traefik router's .service value must reference a service that actually has a +// loadbalancer.server.port definition. A mismatch (e.g. a double "-secured" +// suffix) means Traefik silently drops that route. Checked across nginx (which +// emits the most routers, incl. the multisite wildcard), pma and mailpit. +func TestEveryRouterServicePointerHasDefinition(t *testing.T) { + v := baseView() + v.MultisiteEnabled = true + for name, labels := range map[string]map[string]string{ + "nginx": nginxLabels(v), + "pma": phpMyAdminLabels(v), + "mailpit": mailpitLabels(v), + } { + defined := map[string]bool{} + for k := range labels { + const pre, suf = "traefik.http.services.", ".loadbalancer.server.port" + if strings.HasPrefix(k, pre) && strings.HasSuffix(k, suf) { + defined[strings.TrimSuffix(strings.TrimPrefix(k, pre), suf)] = true + } + } + for k, val := range labels { + if strings.HasPrefix(k, "traefik.http.routers.") && strings.HasSuffix(k, ".service") { + if !defined[val] { + t.Fatalf("[%s] router %s points at undefined service %q; defined=%v", name, k, val, defined) + } + } + if strings.Contains(k, "-secured-secured") { + t.Fatalf("[%s] malformed double-secured key: %s", name, k) + } + } + } +} + +func labelValueContains(labels map[string]string, keySub, valSub string) bool { + for k, val := range labels { + if strings.Contains(k, keySub) && strings.Contains(val, valSub) { + return true + } + } + return false +} +func anyLabelKeyContains(labels map[string]string, sub string) bool { + for k := range labels { + if strings.Contains(k, sub) { + return true + } + } + return false +} +func anyLabelValueContains(labels map[string]string, sub string) bool { + for _, val := range labels { + if strings.Contains(val, sub) { + return true + } + } + return false +} diff --git a/internal/devenv/compose/project.go b/internal/devenv/compose/project.go new file mode 100644 index 000000000..7fc49b5d4 --- /dev/null +++ b/internal/devenv/compose/project.go @@ -0,0 +1,88 @@ +package compose + +// BuildProject assembles the full compose Project for an environment. +func BuildProject(v View) *Project { + p := &Project{ + // Name must equal the slug the runner passes via `-p ` (which + // wins over compose's top-level name: anyway). Aligning them removes + // the Plan-4 ambiguity so exec/logs/ps all key off one project name. + Name: v.SiteSlug, + Services: map[string]*Service{}, + Volumes: map[string]*TopLevelVolume{}, + // ProjectNetwork is per-env (compose names it `_default`) and carries + // the bare service-name aliases; ProxyNetwork is the shared external proxy + // net. See the ProxyNetwork/ProjectNetwork docs for why backends must stay + // off the shared net (cross-env `database` alias collision). + Networks: map[string]*Network{ + ProjectNetwork: {}, + ProxyNetwork: {External: true, Name: ProxyNetwork}, + }, + } + + // Always-on services. + p.Services["database"] = databaseService(v) + p.Services["memcached"] = memcachedService() + p.Services["php"] = phpService(v) + nginx := nginxService(v) + nginx.Labels = nginxLabels(v) + p.Services["nginx"] = nginx + p.Services["wordpress"] = wordpressService(v) + + // Conditional services. + if v.PHPMyAdmin { + pma := phpMyAdminService() + pma.Labels = phpMyAdminLabels(v) + p.Services["phpmyadmin"] = pma + } + if v.Elasticsearch { + p.Services["elasticsearch"] = elasticsearchService() + } + if v.Mailpit { + mp := mailpitService() + mp.Labels = mailpitLabels(v) + p.Services["mailpit"] = mp + } + if v.Photon { + p.Services["photon"] = photonService() + } + if !v.MuPluginsLocal { + p.Services["vip-mu-plugins"] = vipMuPluginsService(v) + } + if !v.AppCodeLocal { + p.Services["demo-app-code"] = demoAppCodeService() + } + + declareVolumes(p, v) + return p +} + +// declareVolumes declares each named volume referenced by an enabled service, +// marking it external (mapped to a Lando volume name) when migrating. +func declareVolumes(p *Project, v View) { + names := []string{"database_data", "devtools", "scripts"} + if !v.MuPluginsLocal { + names = append(names, "mu-plugins") + } + if !v.AppCodeLocal { + names = append(names, + "clientcode_clientmuPlugins", "clientcode_images", "clientcode_languages", + "clientcode_plugins", "clientcode_private", "clientcode_themes", "clientcode_vipconfig") + } + if v.Elasticsearch { + names = append(names, "search_data") + } + if v.PHPMyAdmin { + names = append(names, "pma_www") + } + + for _, n := range names { + tv := &TopLevelVolume{} + if v.Migrate { + if ext, ok := v.ExternalVolumeNames[n]; ok && ext != "" { + tv.External = true + tv.Name = ext + } + } + p.Volumes[n] = tv + } +} diff --git a/internal/devenv/compose/project_test.go b/internal/devenv/compose/project_test.go new file mode 100644 index 000000000..05d05d45b --- /dev/null +++ b/internal/devenv/compose/project_test.go @@ -0,0 +1,148 @@ +package compose + +import "testing" + +func TestBuildProjectAlwaysOnServices(t *testing.T) { + v := baseView() + p := BuildProject(v) + for _, name := range []string{"database", "memcached", "php", "nginx", "wordpress"} { + if _, ok := p.Services[name]; !ok { + t.Fatalf("missing always-on service %q", name) + } + } + if p.Services["nginx"].Labels["traefik.enable"] != "true" { + t.Fatalf("nginx missing traefik labels") + } + if p.Name != "example" { + t.Fatalf("project name = %q, want example (bare slug, must match -p)", p.Name) + } + if p.Networks[ProxyNetwork] == nil || !p.Networks[ProxyNetwork].External { + t.Fatalf("proxy network not declared external: %+v", p.Networks) + } + if p.Volumes["mu-plugins"] == nil { + t.Fatalf("mu-plugins volume not declared in image mode") + } + if p.Volumes["clientcode_themes"] == nil { + t.Fatalf("clientcode_themes volume not declared in image mode") + } + if p.Volumes["database_data"] == nil { + t.Fatalf("database_data volume missing") + } +} + +func TestBuildProjectConditionalServices(t *testing.T) { + v := baseView() + v.PHPMyAdmin = true + v.Elasticsearch = true + v.Mailpit = true + v.Photon = true + p := BuildProject(v) + for _, name := range []string{"phpmyadmin", "elasticsearch", "mailpit", "photon"} { + if _, ok := p.Services[name]; !ok { + t.Fatalf("missing conditional service %q", name) + } + } + if p.Volumes["search_data"] == nil || p.Volumes["pma_www"] == nil { + t.Fatalf("conditional volumes missing: %+v", p.Volumes) + } + if p.Services["phpmyadmin"].Labels["traefik.enable"] != "true" { + t.Fatalf("pma missing labels") + } +} + +func TestBuildProjectLocalModeOmitsInitServicesAndVolumes(t *testing.T) { + v := baseView() + v.MuPluginsLocal = true + v.MuPluginsDir = "/srv/mu" + v.AppCodeLocal = true + v.AppCodeDir = "/srv/app" + p := BuildProject(v) + if _, ok := p.Services["vip-mu-plugins"]; ok { + t.Fatalf("vip-mu-plugins should be absent in local mu-plugins mode") + } + if _, ok := p.Services["demo-app-code"]; ok { + t.Fatalf("demo-app-code should be absent in local appCode mode") + } + if p.Volumes["mu-plugins"] != nil || p.Volumes["clientcode_themes"] != nil { + t.Fatalf("named content volumes should be absent in local mode: %+v", p.Volumes) + } +} + +func TestBuildProjectExternalVolumesWhenMigrating(t *testing.T) { + v := baseView() + v.Migrate = true + v.ExternalVolumeNames = map[string]string{"database_data": "landovipdevexample_database_data"} + p := BuildProject(v) + dv := p.Volumes["database_data"] + if dv == nil || !dv.External || dv.Name != "landovipdevexample_database_data" { + t.Fatalf("database_data not mapped to external Lando name: %+v", dv) + } +} + +// netHas reports whether a service's Networks list contains net. +func netHas(s *Service, net string) bool { + for _, n := range s.Networks { + if n == net { + return true + } + } + return false +} + +// TestBuildProjectNetworkIsolation guards against the cross-environment DB bleed +// bug: backend services must NOT join the shared external proxy network (where a +// bare `database` alias from every env collides under Docker round-robin DNS). +// They live on the per-project network only; the proxy network carries just the +// Traefik-routed edge services. +func TestBuildProjectNetworkIsolation(t *testing.T) { + v := baseView() + v.PHPMyAdmin = true + v.Elasticsearch = true + v.Mailpit = true + v.Photon = true + p := BuildProject(v) + + // The per-project network is declared and is NOT external (each env gets its + // own `_default`, so bare service names resolve within the env only). + if p.Networks[ProjectNetwork] == nil { + t.Fatalf("per-project network %q not declared: %+v", ProjectNetwork, p.Networks) + } + if p.Networks[ProjectNetwork].External { + t.Fatalf("per-project network %q must NOT be external (would re-collide across envs)", ProjectNetwork) + } + + // Backends must be on the per-project network and OFF the shared proxy net. + backends := []string{"database", "memcached", "php", "wordpress", "elasticsearch", "photon", "vip-mu-plugins", "demo-app-code"} + for _, name := range backends { + s, ok := p.Services[name] + if !ok { + t.Fatalf("expected backend service %q", name) + } + if !netHas(s, ProjectNetwork) { + t.Errorf("backend %q not on per-project network %q: %v", name, ProjectNetwork, s.Networks) + } + if netHas(s, ProxyNetwork) { + t.Errorf("backend %q must NOT be on shared proxy network %q (cross-env collision): %v", name, ProxyNetwork, s.Networks) + } + } + + // Edge (Traefik-routed) services must be on BOTH networks: the proxy net so + // the shared Traefik can reach them, and the per-project net to reach backends. + for _, name := range []string{"nginx", "phpmyadmin", "mailpit"} { + s, ok := p.Services[name] + if !ok { + t.Fatalf("expected edge service %q", name) + } + if !netHas(s, ProjectNetwork) || !netHas(s, ProxyNetwork) { + t.Errorf("edge %q must be on both %q and %q: %v", name, ProjectNetwork, ProxyNetwork, s.Networks) + } + } +} + +func TestBuildProjectNameMatchesSlug(t *testing.T) { + v := View{SiteSlug: "example-site"} + p := BuildProject(v) + if p.Name != "example-site" { + t.Fatalf("Project.Name = %q, want the bare slug %q (must match the `-p ` the runner passes)", p.Name, "example-site") + } +} diff --git a/internal/devenv/compose/render.go b/internal/devenv/compose/render.go new file mode 100644 index 000000000..7d0f474a1 --- /dev/null +++ b/internal/devenv/compose/render.go @@ -0,0 +1,54 @@ +package compose + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +// RenderCompose marshals the assembled project to docker-compose.yml bytes. +func RenderCompose(v View) ([]byte, error) { + return yaml.Marshal(BuildProject(v)) +} + +// RenderEnvFile renders the .env file consumed by services (host UID/GID). +func RenderEnvFile(v View) string { + return fmt.Sprintf("LANDO_HOST_USER_ID=%s\nLANDO_HOST_GROUP_ID=%s\n", v.HostUID, v.HostGID) +} + +// RenderNginxConf renders nginx/extra.conf. The Node template is currently +// empty boilerplate; emit a minimal valid file. +func RenderNginxConf(_ View) string { + return "# VIP dev-env extra nginx configuration\n" +} + +// SetupStep is a post-start command the lifecycle runs in the php service. +type SetupStep struct { + AsRoot bool + Command string +} + +// SetupSteps ports the EJS php run_as_root + run steps (lines 88-101): chown +// the WordPress content paths to www-data (root), then run setup.sh as the +// service user. The lifecycle (Plan 4) executes these after `up`. +func SetupSteps(v View) []SetupStep { + steps := []SetupStep{ + {AsRoot: true, Command: "chown www-data:www-data /wp/wp-content/mu-plugins /wp/config /wp/log /wp/wp-content/uploads /wp"}, + } + if !v.AppCodeLocal { + steps = append(steps, SetupStep{AsRoot: true, Command: "chown www-data:www-data /wp/wp-content/plugins"}) + } + + var b strings.Builder + fmt.Fprintf(&b, `sh /dev-tools/setup.sh --host database --user root --domain "http://%s.%s/" --title "%s" --wpadmin_password "%s"`, + v.SiteSlug, v.Domain, v.WPTitle, v.AdminPassword) + if v.MultisiteEnabled { + fmt.Fprintf(&b, ` --ms-domain "%s.%s"`, v.SiteSlug, v.Domain) + if v.MultisiteSubdomain { + b.WriteString(" --subdomain") + } + } + steps = append(steps, SetupStep{AsRoot: false, Command: b.String()}) + return steps +} diff --git a/internal/devenv/compose/render_test.go b/internal/devenv/compose/render_test.go new file mode 100644 index 000000000..8dc1aeb79 --- /dev/null +++ b/internal/devenv/compose/render_test.go @@ -0,0 +1,90 @@ +package compose + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Automattic/vip/internal/devenv/instancedata" +) + +func TestRenderEnvFile(t *testing.T) { + v := baseView() + out := RenderEnvFile(v) + if !strings.Contains(out, "LANDO_HOST_USER_ID=1000") || !strings.Contains(out, "LANDO_HOST_GROUP_ID=1000") { + t.Fatalf(".env missing host ids:\n%s", out) + } +} + +func TestSetupStepsIncludeChownAndSetup(t *testing.T) { + v := baseView() + steps := SetupSteps(v) + var sawChown, sawSetup bool + for _, s := range steps { + if s.AsRoot && strings.Contains(s.Command, "chown www-data:www-data") { + sawChown = true + } + if !s.AsRoot && strings.Contains(s.Command, "/dev-tools/setup.sh") { + sawSetup = true + if !strings.Contains(s.Command, `--domain "http://example.vipdev.lndo.site/"`) { + t.Fatalf("setup.sh domain wrong: %q", s.Command) + } + } + } + if !sawChown || !sawSetup { + t.Fatalf("expected chown + setup steps, got %+v", steps) + } +} + +func TestSetupStepsMultisiteFlags(t *testing.T) { + v := baseView() + v.MultisiteEnabled = true + v.MultisiteSubdomain = true + steps := SetupSteps(v) + var setup string + for _, s := range steps { + if strings.Contains(s.Command, "setup.sh") { + setup = s.Command + } + } + if !strings.Contains(setup, "--ms-domain") || !strings.Contains(setup, "--subdomain") { + t.Fatalf("multisite subdomain flags missing: %q", setup) + } +} + +func TestRenderComposeMatchesGolden(t *testing.T) { + data := &instancedata.InstanceData{ + SiteSlug: "example", + WPTitle: "Example Dev", + Multisite: json.RawMessage("false"), + WordPress: instancedata.WordPressConfig{Mode: "image", Tag: "trunk"}, + MuPlugins: instancedata.ComponentConfig{Mode: "image"}, + AppCode: instancedata.ComponentConfig{Mode: "image"}, + PHP: "ghcr.io/automattic/vip-container-images/php-fpm:8.2", + PHPMyAdmin: true, + } + v := NewView(data, Options{}) + out, err := RenderCompose(v) + if err != nil { + t.Fatalf("RenderCompose: %v", err) + } + + goldenPath := filepath.Join("testdata", "full.golden.yml") + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(goldenPath, out, 0o644); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("read golden (run with UPDATE_GOLDEN=1 once to create): %v", err) + } + if string(out) != string(want) { + t.Fatalf("compose output differs from golden.\n--- got ---\n%s\n--- want ---\n%s", out, want) + } +} diff --git a/internal/devenv/compose/services.go b/internal/devenv/compose/services.go new file mode 100644 index 000000000..c81e7d1ca --- /dev/null +++ b/internal/devenv/compose/services.go @@ -0,0 +1,337 @@ +package compose + +import ( + "fmt" + "strings" +) + +// backendNetworks attaches a service to the per-project network only. Backend +// services must never join ProxyNetwork: their bare service-name alias (e.g. +// `database`) collides across environments on that shared network, so Docker +// round-robin DNS would route one env's traffic to another's. See ProxyNetwork. +func backendNetworks() []string { return []string{ProjectNetwork} } + +// edgeNetworks attaches a Traefik-routed service to both the per-project network +// (so it can reach backends like php/database) and the shared proxy network (so +// the central Traefik proxy can route to it). Only nginx/phpmyadmin/mailpit — +// the services carrying traefik.enable labels — use this. +func edgeNetworks() []string { return []string{ProjectNetwork, ProxyNetwork} } + +// databaseService ports the EJS database service (lines 103-126): a mariadb +// or mysql container with VIP's sql-mode flags and the wordpress DB. +func databaseService(v View) *Service { + isMariaDB := strings.HasPrefix(v.DatabaseImage, "mariadb:") + var command string + if isMariaDB { + command = `docker-entrypoint.sh mysqld --sql-mode=ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION --max_allowed_packet=67M` + } else { + command = `docker-entrypoint.sh mysqld --sql-mode=ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION --max_allowed_packet=67M --mysql-native-password=ON` + } + return &Service{ + Image: v.DatabaseImage, + Command: command, + Ports: []string{":3306"}, + Environment: map[string]string{ + "MYSQL_ALLOW_EMPTY_PASSWORD": "true", + "MYSQL_USER": "wordpress", + "MYSQL_PASSWORD": "wordpress", + "MYSQL_DATABASE": "wordpress", + "LANDO_NO_USER_PERMS": "1", + "LANDO_NO_SCRIPTS": "1", + "LANDO_NEEDS_EXEC": "1", + }, + Volumes: []VolumeMount{{Short: "database_data:/var/lib/mysql"}}, + Networks: backendNetworks(), + } +} + +// memcachedService ports the EJS memcached service (lines 128-136). +func memcachedService() *Service { + return &Service{ + Image: "memcached:1.6-alpine", + Command: "memcached -m 64", + Environment: map[string]string{ + "LANDO_NO_USER_PERMS": "1", + "LANDO_NO_SCRIPTS": "1", + "LANDO_NEEDS_EXEC": "1", + }, + Networks: backendNetworks(), + } +} + +// wpVolumes ports the EJS wpVolumes() function (lines 307-368): the shared +// WordPress content mounts, differing for image vs local muPlugins/appCode. +func wpVolumes(v View) []VolumeMount { + // Order matters: a parent mount must precede its nested mounts. The EJS + // template lists ./wordpress:/wp last, which only works under the docker + // compose plugin (it sorts mounts by destination depth). Standalone + // docker-compose mounts in file order, so ./wordpress:/wp listed after + // ./config:/wp/config would shadow /wp/config (and /wp/log, /wp/.../uploads), + // making them inaccessible -> the run_as_root chown fails. Mount /wp first, + // then its children; /wp/config/integrations-config comes after /wp/config. + vols := []VolumeMount{ + {Short: "./wordpress:/wp"}, + {Short: "./config:/wp/config"}, + {Short: "./log:/wp/log"}, + {Short: "./uploads:/wp/wp-content/uploads"}, + {Short: "./integrations-config:/wp/config/integrations-config"}, + } + + if v.MuPluginsLocal { + vols = append(vols, VolumeMount{Short: v.MuPluginsDir + ":/wp/wp-content/mu-plugins"}) + } else { + vols = append(vols, VolumeMount{Type: "volume", Source: "mu-plugins", Target: "/wp/wp-content/mu-plugins", NoCopy: true}) + } + + if v.AppCodeLocal { + d := v.AppCodeDir + vols = append(vols, + VolumeMount{Short: d + "/client-mu-plugins:/wp/wp-content/client-mu-plugins"}, + VolumeMount{Short: d + "/images:/wp/wp-content/images"}, + VolumeMount{Short: d + "/languages:/wp/wp-content/languages"}, + VolumeMount{Short: d + "/plugins:/wp/wp-content/plugins"}, + VolumeMount{Short: d + "/private:/wp/wp-content/private"}, + VolumeMount{Short: d + "/themes:/wp/wp-content/themes"}, + VolumeMount{Short: d + "/vip-config:/wp/vip-config"}, + ) + } else { + for _, m := range []struct{ src, tgt string }{ + {"clientcode_clientmuPlugins", "/wp/wp-content/client-mu-plugins"}, + {"clientcode_images", "/wp/wp-content/images"}, + {"clientcode_languages", "/wp/wp-content/languages"}, + {"clientcode_plugins", "/wp/wp-content/plugins"}, + {"clientcode_private", "/wp/wp-content/private"}, + {"clientcode_themes", "/wp/wp-content/themes"}, + {"clientcode_vipconfig", "/wp/vip-config"}, + } { + vols = append(vols, VolumeMount{Type: "volume", Source: m.src, Target: m.tgt, NoCopy: true}) + } + } + return vols +} + +// nginxService ports the EJS nginx service (lines 22-34). +func nginxService(v View) *Service { + vols := append([]VolumeMount{{Short: "./nginx/extra.conf:/etc/nginx/conf.extra/extra.conf"}}, wpVolumes(v)...) + return &Service{ + Image: "ghcr.io/automattic/vip-container-images/nginx:latest", + Entrypoint: `/usr/sbin/nginx -g "daemon off;"`, + Volumes: vols, + DependsOn: map[string]DependsOn{"php": {Condition: "service_started"}}, + Networks: edgeNetworks(), + } +} + +// phpService ports the EJS php service (lines 36-101) WITHOUT the run / +// run_as_root steps (those become SetupSteps in Task 9). +func phpService(v View) *Service { + env := map[string]string{ + "LANDO_NO_USER_PERMS": "enable", + "LANDO_NEEDS_EXEC": "1", + // LANDO_APP_NAME is the env slug. Lando set this automatically; the Go + // port must, so the shared php-fpm image's bash.bashrc banner ("shell: + // ") and other LANDO_APP_NAME-dependent tooling resolve it. Set + // before the user-env loop below so it stays reserved. + "LANDO_APP_NAME": v.SiteSlug, + } + if v.Xdebug { + env["XDEBUG"] = "enable" + } else { + env["XDEBUG"] = "disable" + } + if v.XdebugConfig != "" { + env["XDEBUG_CONFIG"] = v.XdebugConfig + } + if v.AutologinKey != "" { + env["VIP_DEV_AUTOLOGIN_KEY"] = v.AutologinKey + } + if v.Cron { + env["ENABLE_CRON"] = "1" + } + + dep := map[string]DependsOn{ + "database": {Condition: "service_started"}, + "memcached": {Condition: "service_started"}, + "wordpress": {Condition: "service_completed_successfully"}, + } + if v.Elasticsearch { + dep["elasticsearch"] = DependsOn{Condition: "service_started"} + } + if !v.MuPluginsLocal { + dep["vip-mu-plugins"] = DependsOn{Condition: "service_started"} + } + if !v.AppCodeLocal { + dep["demo-app-code"] = DependsOn{Condition: "service_completed_successfully"} + } + + vols := append([]VolumeMount{ + {Type: "volume", Source: "devtools", Target: "/dev-tools", NoCopy: true}, + {Type: "volume", Source: "scripts", Target: "/scripts", NoCopy: true}, + }, wpVolumes(v)...) + + // User env vars (Plan 5 envvar) are injected last but never override a + // reserved LANDO_*/XDEBUG/etc. key already set above. + for k, val := range v.EnvVars { + if _, reserved := env[k]; !reserved { + env[k] = val + } + } + + return &Service{ + Image: v.PHPImage, + Command: "run.sh", + WorkingDir: "/wp", + EnvFile: []string{".env"}, + Environment: env, + DependsOn: dep, + Volumes: vols, + Networks: backendNetworks(), + } +} + +// wordpressService ports the EJS wordpress init service (lines 191-203). It is +// a run-once (initOnly) container that rsyncs the WP core + dev-tools into +// shared volumes; the initOnly semantics are lifecycle metadata (Task 9). +func wordpressService(v View) *Service { + entry := fmt.Sprintf(`/bin/sh -c '/usr/bin/rsync -ac --delete --chown=%s:%s /wp/ /shared/; /usr/bin/rsync -ac --chown=%s:%s --delete /dev-tools-orig/ /dev-tools/'`, + "${LANDO_HOST_USER_ID}", "${LANDO_HOST_GROUP_ID}", "${LANDO_HOST_USER_ID}", "${LANDO_HOST_GROUP_ID}") + return &Service{ + Image: v.WordPressImage, + Entrypoint: entry, + Volumes: []VolumeMount{ + {Short: "./wordpress:/shared"}, + {Short: "devtools:/dev-tools"}, + {Short: "scripts:/scripts"}, + }, + Networks: backendNetworks(), + } +} + +// phpMyAdminService ports the EJS phpmyadmin service (lines 138-161). +func phpMyAdminService() *Service { + return &Service{ + Image: "phpmyadmin:5", + Command: "/docker-entrypoint.sh apache2-foreground", + Ports: []string{"127.0.0.1::80"}, + Environment: map[string]string{ + "MYSQL_ROOT_PASSWORD": "", + "PMA_HOSTS": "database", + "PMA_PORT": "3306", + "PMA_USER": "root", + "PMA_PASSWORD": "", + "UPLOAD_LIMIT": "4G", + "LANDO_NO_USER_PERMS": "1", + "LANDO_NEEDS_EXEC": "1", + }, + Volumes: []VolumeMount{{Short: "pma_www:/var/www/html"}}, + Networks: edgeNetworks(), + } +} + +// elasticsearchService ports the EJS elasticsearch service (lines 163-189). +func elasticsearchService() *Service { + return &Service{ + Image: "elasticsearch:8.18.2", + Command: "/usr/local/bin/docker-entrypoint.sh", + Ports: []string{":9200"}, + Deploy: &Deploy{Resources: Resources{Limits: ResourceLimits{Memory: "1GB"}}}, + Environment: map[string]string{ + "ELASTICSEARCH_IS_DEDICATED_NODE": "no", + "ELASTICSEARCH_CLUSTER_NAME": "bespin", + "ELASTICSEARCH_NODE_NAME": "lando", + "ELASTICSEARCH_PORT_NUMBER": "9200", + "discovery.type": "single-node", + "xpack.security.enabled": "false", + "LANDO_NO_USER_PERMS": "1", + "LANDO_NO_SCRIPTS": "1", + "LANDO_NEEDS_EXEC": "1", + }, + Volumes: []VolumeMount{{Short: "search_data:/usr/share/elasticsearch/data"}}, + Networks: backendNetworks(), + } +} + +// mailpitService ports the EJS mailpit service (lines 256-270). The EJS sets +// `command: /mailpit`, but that only worked under Lando (which strips the image +// entrypoint). The axllent/mailpit image ENTRYPOINT is already ["/mailpit"], so +// in raw docker compose a `command: /mailpit` is appended → `/mailpit /mailpit` +// → "unknown command /mailpit" and the container exits 1. We set NO command and +// let the entrypoint run (same Lando-vs-raw-compose trap as demo-app-code's +// `exit 0`). +func mailpitService() *Service { + return &Service{ + Image: "axllent/mailpit:latest", + Ports: []string{":1025", ":8025"}, + Environment: map[string]string{ + "LANDO_NO_USER_PERMS": "1", + "LANDO_NEEDS_EXEC": "1", + }, + Networks: edgeNetworks(), + } +} + +// photonService ports the EJS photon service (lines 272-284). +func photonService() *Service { + return &Service{ + Image: "ghcr.io/automattic/vip-container-images/photon:latest", + Command: "/usr/sbin/php-fpm", + Environment: map[string]string{ + "LANDO_NO_USER_PERMS": "1", + "LANDO_NO_SCRIPTS": "1", + "LANDO_NEEDS_EXEC": "1", + }, + Volumes: []VolumeMount{{Short: "./uploads:/usr/share/webapps/photon/uploads:ro"}}, + Networks: backendNetworks(), + } +} + +// vipMuPluginsService ports the EJS vip-mu-plugins init service (205-226). +// The View parameter is unused today but kept for builder-call uniformity. +func vipMuPluginsService(_ View) *Service { + return &Service{ + Image: "ghcr.io/automattic/vip-container-images/mu-plugins:0.1", + Command: "/bin/sh /run.sh", + Environment: map[string]string{ + "LANDO_NO_SCRIPTS": "1", + "LANDO_NEEDS_EXEC": "1", + "LANDO_HOST_UID": "${LANDO_HOST_USER_ID}", + "LANDO_HOST_GID": "${LANDO_HOST_GROUP_ID}", + }, + Volumes: []VolumeMount{ + {Short: "mu-plugins:/shared"}, + {Type: "volume", Source: "scripts", Target: "/scripts", NoCopy: true}, + }, + Networks: backendNetworks(), + } +} + +// demoAppCodeService ports the EJS demo-app-code init service (228-254). +func demoAppCodeService() *Service { + vols := []VolumeMount{} + for _, m := range []struct{ src, tgt string }{ + {"clientcode_clientmuPlugins", "/clientcode/client-mu-plugins"}, + {"clientcode_images", "/clientcode/images"}, + {"clientcode_languages", "/clientcode/languages"}, + {"clientcode_plugins", "/clientcode/plugins"}, + {"clientcode_private", "/clientcode/private"}, + {"clientcode_themes", "/clientcode/themes"}, + {"clientcode_vipconfig", "/clientcode/vip-config"}, + } { + vols = append(vols, VolumeMount{Short: m.src + ":" + m.tgt}) + } + return &Service{ + Image: "ghcr.io/automattic/vip-container-images/skeleton:latest", + // EJS uses `command: exit 0`, which worked only because Lando wrapped + // service commands in a shell. Raw docker compose exec's the command + // directly and `exit` is a shell builtin (not a binary), so wrap it in + // `sh -c`. compose shlex-parses the quoted string, keeping "exit 0" as + // one arg → /bin/sh -c "exit 0" → a clean no-op exit. + Command: `/bin/sh -c "exit 0"`, + Environment: map[string]string{ + "LANDO_HOST_UID": "${LANDO_HOST_USER_ID}", + "LANDO_HOST_GID": "${LANDO_HOST_GROUP_ID}", + }, + Volumes: vols, + Networks: backendNetworks(), + } +} diff --git a/internal/devenv/compose/services_test.go b/internal/devenv/compose/services_test.go new file mode 100644 index 000000000..73516b3d5 --- /dev/null +++ b/internal/devenv/compose/services_test.go @@ -0,0 +1,258 @@ +package compose + +import ( + "strings" + "testing" +) + +// baseView is the shared test fixture for service builders (image mode for +// muPlugins/appCode unless a test flips the *Local flags). +func baseView() View { + return View{ + SiteSlug: "example", Domain: "vipdev.lndo.site", DatabaseImage: "mysql:8.4", + WordPressImage: "ghcr.io/automattic/vip-container-images/wordpress:trunk", + PHPImage: "php:8.2", AdminPassword: "password", HostUID: "1000", HostGID: "1000", + } +} + +func TestDatabaseServiceMySQL(t *testing.T) { + svc := databaseService(baseView()) + if svc.Image != "mysql:8.4" { + t.Fatalf("image = %q", svc.Image) + } + if !strings.Contains(svc.Command, "--mysql-native-password=ON") { + t.Fatalf("mysql command missing native-password flag: %q", svc.Command) + } + if svc.Environment["MYSQL_DATABASE"] != "wordpress" { + t.Fatalf("MYSQL_DATABASE = %q", svc.Environment["MYSQL_DATABASE"]) + } + if len(svc.Volumes) != 1 || svc.Volumes[0].Short != "database_data:/var/lib/mysql" { + t.Fatalf("db volume wrong: %+v", svc.Volumes) + } +} + +func TestDatabaseServiceMariaDB(t *testing.T) { + v := baseView() + v.DatabaseImage = "mariadb:10.11" + svc := databaseService(v) + if svc.Image != "mariadb:10.11" { + t.Fatalf("image = %q", svc.Image) + } + if !strings.Contains(svc.Command, "NO_AUTO_CREATE_USER") { + t.Fatalf("mariadb command wrong: %q", svc.Command) + } +} + +func TestMemcachedService(t *testing.T) { + svc := memcachedService() + if svc.Image != "memcached:1.6-alpine" || svc.Command != "memcached -m 64" { + t.Fatalf("memcached wrong: %+v", svc) + } +} + +func TestWPVolumesImageMode(t *testing.T) { + v := baseView() // appCode/muPlugins image mode (locals false) + vols := wpVolumes(v) + must := []string{ + "./config:/wp/config", + "./log:/wp/log", + "./uploads:/wp/wp-content/uploads", + "./wordpress:/wp", + "./integrations-config:/wp/config/integrations-config", + } + for _, m := range must { + if !hasShort(vols, m) { + t.Fatalf("wpVolumes missing %q: %+v", m, vols) + } + } + if !hasNamed(vols, "mu-plugins", "/wp/wp-content/mu-plugins") { + t.Fatalf("expected mu-plugins named volume in image mode") + } +} + +func TestWPVolumesLocalMode(t *testing.T) { + v := baseView() + v.MuPluginsLocal = true + v.MuPluginsDir = "/srv/mu" + v.AppCodeLocal = true + v.AppCodeDir = "/srv/app" + vols := wpVolumes(v) + if !hasShort(vols, "/srv/mu:/wp/wp-content/mu-plugins") { + t.Fatalf("local mu-plugins bind missing: %+v", vols) + } + if !hasShort(vols, "/srv/app/plugins:/wp/wp-content/plugins") { + t.Fatalf("local appCode plugins bind missing: %+v", vols) + } +} + +func TestNginxServiceDependsOnPHP(t *testing.T) { + svc := nginxService(baseView()) + if svc.Image != "ghcr.io/automattic/vip-container-images/nginx:latest" { + t.Fatalf("nginx image = %q", svc.Image) + } + if svc.DependsOn["php"].Condition != "service_started" { + t.Fatalf("nginx should depend_on php service_started: %+v", svc.DependsOn) + } + if !hasShort(svc.Volumes, "./nginx/extra.conf:/etc/nginx/conf.extra/extra.conf") { + t.Fatalf("nginx extra.conf mount missing: %+v", svc.Volumes) + } +} + +func TestPHPServiceDependsAndEnv(t *testing.T) { + v := baseView() + v.Xdebug = true + svc := phpService(v) + if svc.WorkingDir != "/wp" || svc.Command != "run.sh" { + t.Fatalf("php working_dir/command wrong: %+v", svc) + } + if svc.Environment["XDEBUG"] != "enable" { + t.Fatalf("xdebug env = %q, want enable", svc.Environment["XDEBUG"]) + } + if svc.DependsOn["database"].Condition != "service_started" { + t.Fatalf("php depends_on database missing") + } + if svc.DependsOn["wordpress"].Condition != "service_completed_successfully" { + t.Fatalf("php depends_on wordpress completed missing: %+v", svc.DependsOn) + } +} + +// TestPHPServiceSetsAppName guards that the php container exports LANDO_APP_NAME +// (the env slug). Lando injected this automatically; the Go port must set it so +// the shared php-fpm image's /etc/bash.bashrc banner ("shell: ") and any +// LANDO_APP_NAME-dependent tooling work. It is reserved (a user env var of the +// same name must not override it). +func TestPHPServiceSetsAppName(t *testing.T) { + v := baseView() + v.SiteSlug = "my-env" + if got := phpService(v).Environment["LANDO_APP_NAME"]; got != "my-env" { + t.Fatalf("LANDO_APP_NAME = %q, want the slug %q", got, "my-env") + } + v2 := baseView() + v2.SiteSlug = "my-env" + v2.EnvVars = map[string]string{"LANDO_APP_NAME": "hijacked"} + if got := phpService(v2).Environment["LANDO_APP_NAME"]; got != "my-env" { + t.Fatalf("LANDO_APP_NAME overridden by user env var: got %q want %q", got, "my-env") + } +} + +func TestWordPressInitService(t *testing.T) { + svc := wordpressService(baseView()) + if svc.Image != "ghcr.io/automattic/vip-container-images/wordpress:trunk" { + t.Fatalf("wordpress image = %q", svc.Image) + } + if !hasShort(svc.Volumes, "./wordpress:/shared") { + t.Fatalf("wordpress /shared mount missing: %+v", svc.Volumes) + } +} + +func TestPhpMyAdminService(t *testing.T) { + svc := phpMyAdminService() + if svc.Image != "phpmyadmin:5" { + t.Fatalf("pma image = %q", svc.Image) + } + if svc.Environment["PMA_HOSTS"] != "database" { + t.Fatalf("PMA_HOSTS = %q", svc.Environment["PMA_HOSTS"]) + } + if !hasShort(svc.Volumes, "pma_www:/var/www/html") { + t.Fatalf("pma volume missing: %+v", svc.Volumes) + } +} + +func TestElasticsearchServiceMemoryLimit(t *testing.T) { + svc := elasticsearchService() + if svc.Image != "elasticsearch:8.18.2" { + t.Fatalf("es image = %q", svc.Image) + } + if svc.Deploy == nil || svc.Deploy.Resources.Limits.Memory != "1GB" { + t.Fatalf("es memory limit missing: %+v", svc.Deploy) + } +} + +func TestMailpitAndPhotonAndInitServices(t *testing.T) { + if mailpitService().Image != "axllent/mailpit:latest" { + t.Fatal("mailpit image wrong") + } + // The axllent/mailpit image ENTRYPOINT is already ["/mailpit"]; the EJS + // `command: /mailpit` only worked under Lando (which strips the image + // entrypoint). In raw compose, command is appended → `/mailpit /mailpit` → + // "unknown command /mailpit" and the container exits 1. So set NO command and + // let the entrypoint run. + if got := mailpitService().Command; got != "" { + t.Fatalf("mailpit Command = %q, want empty (image entrypoint /mailpit runs it; a command duplicates the entrypoint)", got) + } + if photonService().Image != "ghcr.io/automattic/vip-container-images/photon:latest" { + t.Fatal("photon image wrong") + } + if vipMuPluginsService(baseView()).Image != "ghcr.io/automattic/vip-container-images/mu-plugins:0.1" { + t.Fatal("mu-plugins image wrong") + } + if demoAppCodeService().Image != "ghcr.io/automattic/vip-container-images/skeleton:latest" { + t.Fatal("skeleton image wrong") + } + // The EJS `exit 0` is a shell builtin; raw docker compose exec's the command + // directly (no Lando shell wrapper), so it MUST be shell-wrapped or the init + // container dies with `exec: "exit": executable file not found`. + if got := demoAppCodeService().Command; got != `/bin/sh -c "exit 0"` { + t.Fatalf("demo-app-code command = %q, want a shell-wrapped no-op", got) + } +} + +func TestPHPServiceInjectsEnvVars(t *testing.T) { + v := View{PHPImage: "php:img", EnvVars: map[string]string{"MY_VAR": "v1"}} + svc := phpService(v) + if svc.Environment["MY_VAR"] != "v1" { + t.Fatalf("user env var not injected into php service: %+v", svc.Environment) + } + // Reserved keys must not be overridden by a user var of the same name. + v2 := View{PHPImage: "php:img", EnvVars: map[string]string{"LANDO_NEEDS_EXEC": "0"}} + if got := phpService(v2).Environment["LANDO_NEEDS_EXEC"]; got != "1" { + t.Fatalf("reserved env var was overridden by user var: got %q want \"1\"", got) + } +} + +func hasShort(vols []VolumeMount, s string) bool { + for _, v := range vols { + if v.Short == s { + return true + } + } + return false +} +func hasNamed(vols []VolumeMount, source, target string) bool { + for _, v := range vols { + if v.Short == "" && v.Source == source && v.Target == target { + return true + } + } + return false +} + +// TestWPVolumesMountParentFirst guards the mount-ordering fix: the /wp parent +// bind must precede its nested children, else standalone docker-compose mounts +// ./wordpress:/wp over /wp/config (etc.), hiding them and breaking the chown. +func TestWPVolumesMountParentFirst(t *testing.T) { + vols := wpVolumes(View{}) + idxOf := func(target string) int { + for i, v := range vols { + if v.Short == target { + return i + } + } + return -1 + } + wp := idxOf("./wordpress:/wp") + if wp < 0 { + t.Fatal("./wordpress:/wp mount missing") + } + for _, child := range []string{"./config:/wp/config", "./log:/wp/log", "./uploads:/wp/wp-content/uploads"} { + ci := idxOf(child) + if ci < 0 || ci < wp { + t.Fatalf("%s (idx %d) must be mounted AFTER ./wordpress:/wp (idx %d)", child, ci, wp) + } + } + cfg := idxOf("./config:/wp/config") + ic := idxOf("./integrations-config:/wp/config/integrations-config") + if ic < cfg { + t.Fatalf("integrations-config (idx %d) must be after /wp/config (idx %d)", ic, cfg) + } +} diff --git a/internal/devenv/compose/testdata/full.golden.yml b/internal/devenv/compose/testdata/full.golden.yml new file mode 100644 index 000000000..a3c9a5918 --- /dev/null +++ b/internal/devenv/compose/testdata/full.golden.yml @@ -0,0 +1,265 @@ +name: example +services: + database: + image: mysql:8.4 + command: docker-entrypoint.sh mysqld --sql-mode=ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION --max_allowed_packet=67M --mysql-native-password=ON + environment: + LANDO_NEEDS_EXEC: "1" + LANDO_NO_SCRIPTS: "1" + LANDO_NO_USER_PERMS: "1" + MYSQL_ALLOW_EMPTY_PASSWORD: "true" + MYSQL_DATABASE: wordpress + MYSQL_PASSWORD: wordpress + MYSQL_USER: wordpress + ports: + - :3306 + volumes: + - database_data:/var/lib/mysql + networks: + - default + demo-app-code: + image: ghcr.io/automattic/vip-container-images/skeleton:latest + command: /bin/sh -c "exit 0" + environment: + LANDO_HOST_GID: ${LANDO_HOST_GROUP_ID} + LANDO_HOST_UID: ${LANDO_HOST_USER_ID} + volumes: + - clientcode_clientmuPlugins:/clientcode/client-mu-plugins + - clientcode_images:/clientcode/images + - clientcode_languages:/clientcode/languages + - clientcode_plugins:/clientcode/plugins + - clientcode_private:/clientcode/private + - clientcode_themes:/clientcode/themes + - clientcode_vipconfig:/clientcode/vip-config + networks: + - default + memcached: + image: memcached:1.6-alpine + command: memcached -m 64 + environment: + LANDO_NEEDS_EXEC: "1" + LANDO_NO_SCRIPTS: "1" + LANDO_NO_USER_PERMS: "1" + networks: + - default + nginx: + image: ghcr.io/automattic/vip-container-images/nginx:latest + entrypoint: /usr/sbin/nginx -g "daemon off;" + depends_on: + php: + condition: service_started + volumes: + - ./nginx/extra.conf:/etc/nginx/conf.extra/extra.conf + - ./wordpress:/wp + - ./config:/wp/config + - ./log:/wp/log + - ./uploads:/wp/wp-content/uploads + - ./integrations-config:/wp/config/integrations-config + - type: volume + source: mu-plugins + target: /wp/wp-content/mu-plugins + volume: + nocopy: true + - type: volume + source: clientcode_clientmuPlugins + target: /wp/wp-content/client-mu-plugins + volume: + nocopy: true + - type: volume + source: clientcode_images + target: /wp/wp-content/images + volume: + nocopy: true + - type: volume + source: clientcode_languages + target: /wp/wp-content/languages + volume: + nocopy: true + - type: volume + source: clientcode_plugins + target: /wp/wp-content/plugins + volume: + nocopy: true + - type: volume + source: clientcode_private + target: /wp/wp-content/private + volume: + nocopy: true + - type: volume + source: clientcode_themes + target: /wp/wp-content/themes + volume: + nocopy: true + - type: volume + source: clientcode_vipconfig + target: /wp/vip-config + volume: + nocopy: true + labels: + traefik.enable: "true" + traefik.http.routers.nginx-example-secured.entrypoints: https + traefik.http.routers.nginx-example-secured.rule: HostRegexp(`example.vipdev.site`) + traefik.http.routers.nginx-example-secured.service: nginx-example-secured-service + traefik.http.routers.nginx-example-secured.tls: "true" + traefik.http.routers.nginx-example.entrypoints: http + traefik.http.routers.nginx-example.rule: HostRegexp(`example.vipdev.site`) + traefik.http.routers.nginx-example.service: nginx-example-service + traefik.http.services.nginx-example-secured-service.loadbalancer.server.port: "80" + traefik.http.services.nginx-example-service.loadbalancer.server.port: "80" + networks: + - default + - vip-dev-env + php: + image: ghcr.io/automattic/vip-container-images/php-fpm:8.2 + command: run.sh + working_dir: /wp + env_file: + - .env + environment: + LANDO_APP_NAME: example + LANDO_NEEDS_EXEC: "1" + LANDO_NO_USER_PERMS: enable + XDEBUG: disable + depends_on: + database: + condition: service_started + demo-app-code: + condition: service_completed_successfully + memcached: + condition: service_started + vip-mu-plugins: + condition: service_started + wordpress: + condition: service_completed_successfully + volumes: + - type: volume + source: devtools + target: /dev-tools + volume: + nocopy: true + - type: volume + source: scripts + target: /scripts + volume: + nocopy: true + - ./wordpress:/wp + - ./config:/wp/config + - ./log:/wp/log + - ./uploads:/wp/wp-content/uploads + - ./integrations-config:/wp/config/integrations-config + - type: volume + source: mu-plugins + target: /wp/wp-content/mu-plugins + volume: + nocopy: true + - type: volume + source: clientcode_clientmuPlugins + target: /wp/wp-content/client-mu-plugins + volume: + nocopy: true + - type: volume + source: clientcode_images + target: /wp/wp-content/images + volume: + nocopy: true + - type: volume + source: clientcode_languages + target: /wp/wp-content/languages + volume: + nocopy: true + - type: volume + source: clientcode_plugins + target: /wp/wp-content/plugins + volume: + nocopy: true + - type: volume + source: clientcode_private + target: /wp/wp-content/private + volume: + nocopy: true + - type: volume + source: clientcode_themes + target: /wp/wp-content/themes + volume: + nocopy: true + - type: volume + source: clientcode_vipconfig + target: /wp/vip-config + volume: + nocopy: true + networks: + - default + phpmyadmin: + image: phpmyadmin:5 + command: /docker-entrypoint.sh apache2-foreground + environment: + LANDO_NEEDS_EXEC: "1" + LANDO_NO_USER_PERMS: "1" + MYSQL_ROOT_PASSWORD: "" + PMA_HOSTS: database + PMA_PASSWORD: "" + PMA_PORT: "3306" + PMA_USER: root + UPLOAD_LIMIT: 4G + ports: + - 127.0.0.1::80 + volumes: + - pma_www:/var/www/html + labels: + traefik.enable: "true" + traefik.http.routers.pma-example-secured.entrypoints: https + traefik.http.routers.pma-example-secured.rule: HostRegexp(`example-pma.vipdev.site`) + traefik.http.routers.pma-example-secured.service: pma-example-secured-service + traefik.http.routers.pma-example-secured.tls: "true" + traefik.http.routers.pma-example.entrypoints: http + traefik.http.routers.pma-example.rule: HostRegexp(`example-pma.vipdev.site`) + traefik.http.routers.pma-example.service: pma-example-service + traefik.http.services.pma-example-secured-service.loadbalancer.server.port: "80" + traefik.http.services.pma-example-service.loadbalancer.server.port: "80" + networks: + - default + - vip-dev-env + vip-mu-plugins: + image: ghcr.io/automattic/vip-container-images/mu-plugins:0.1 + command: /bin/sh /run.sh + environment: + LANDO_HOST_GID: ${LANDO_HOST_GROUP_ID} + LANDO_HOST_UID: ${LANDO_HOST_USER_ID} + LANDO_NEEDS_EXEC: "1" + LANDO_NO_SCRIPTS: "1" + volumes: + - mu-plugins:/shared + - type: volume + source: scripts + target: /scripts + volume: + nocopy: true + networks: + - default + wordpress: + image: ghcr.io/automattic/vip-container-images/wordpress:trunk + entrypoint: /bin/sh -c '/usr/bin/rsync -ac --delete --chown=${LANDO_HOST_USER_ID}:${LANDO_HOST_GROUP_ID} /wp/ /shared/; /usr/bin/rsync -ac --chown=${LANDO_HOST_USER_ID}:${LANDO_HOST_GROUP_ID} --delete /dev-tools-orig/ /dev-tools/' + volumes: + - ./wordpress:/shared + - devtools:/dev-tools + - scripts:/scripts + networks: + - default +volumes: + clientcode_clientmuPlugins: {} + clientcode_images: {} + clientcode_languages: {} + clientcode_plugins: {} + clientcode_private: {} + clientcode_themes: {} + clientcode_vipconfig: {} + database_data: {} + devtools: {} + mu-plugins: {} + pma_www: {} + scripts: {} +networks: + default: {} + vip-dev-env: + external: true + name: vip-dev-env diff --git a/internal/devenv/compose/types.go b/internal/devenv/compose/types.go new file mode 100644 index 000000000..18605db08 --- /dev/null +++ b/internal/devenv/compose/types.go @@ -0,0 +1,97 @@ +// Package compose renders a docker-compose.yml (plus .env and nginx +// extra.conf) for a vip dev environment from an instancedata.InstanceData. +// Ports assets/dev-env.lando.template.yml.ejs to a real compose file: the +// Lando type:compose services map ~1:1 to compose services; the Lando +// proxy:/ssl: keys become Traefik labels; run/run_as_root/initOnly become +// lifecycle metadata (SetupSteps). Output is a typed model marshaled with +// yaml.v3 for guaranteed-valid, deterministic YAML. +package compose + +// Project is the top-level docker-compose document. +type Project struct { + Name string `yaml:"name"` + Services map[string]*Service `yaml:"services"` + Volumes map[string]*TopLevelVolume `yaml:"volumes,omitempty"` + Networks map[string]*Network `yaml:"networks,omitempty"` +} + +// Service is one compose service. Field order here is the YAML emission order. +type Service struct { + Image string `yaml:"image,omitempty"` + Command string `yaml:"command,omitempty"` + Entrypoint string `yaml:"entrypoint,omitempty"` + WorkingDir string `yaml:"working_dir,omitempty"` + EnvFile []string `yaml:"env_file,omitempty"` + Environment map[string]string `yaml:"environment,omitempty"` + Ports []string `yaml:"ports,omitempty"` + DependsOn map[string]DependsOn `yaml:"depends_on,omitempty"` + Volumes []VolumeMount `yaml:"volumes,omitempty"` + Labels map[string]string `yaml:"labels,omitempty"` + Networks []string `yaml:"networks,omitempty"` + Deploy *Deploy `yaml:"deploy,omitempty"` +} + +// DependsOn models the long-form depends_on condition. +type DependsOn struct { + Condition string `yaml:"condition"` +} + +// Deploy models the subset of deploy we emit (elasticsearch memory limit). +type Deploy struct { + Resources Resources `yaml:"resources"` +} + +type Resources struct { + Limits ResourceLimits `yaml:"limits"` +} + +type ResourceLimits struct { + Memory string `yaml:"memory"` +} + +// TopLevelVolume is a named volume. When External is true the volume is +// expected to already exist (used for migrating Lando-created data volumes); +// Name then carries the externally-managed volume name. +type TopLevelVolume struct { + External bool `yaml:"external,omitempty"` + Name string `yaml:"name,omitempty"` +} + +// Network is a top-level network reference (the shared proxy network is +// declared external so all environments share it). +type Network struct { + External bool `yaml:"external,omitempty"` + Name string `yaml:"name,omitempty"` +} + +// VolumeMount is one entry of a service's volumes:. Short, when set, emits the +// compact "src:dst[:opts]" string form. Otherwise the long mapping form is +// emitted (used for named volumes with the nocopy option). +type VolumeMount struct { + Short string // compact form; if set, the long fields are ignored + Type string // long form: "volume" or "bind" + Source string + Target string + NoCopy bool +} + +// MarshalYAML emits either the short string or the long mapping form. +func (v VolumeMount) MarshalYAML() (any, error) { + if v.Short != "" { + return v.Short, nil + } + type vol struct { + Nocopy bool `yaml:"nocopy"` + } + type longForm struct { + Type string `yaml:"type"` + Source string `yaml:"source"` + Target string `yaml:"target"` + Volume *vol `yaml:"volume,omitempty"` + } + lf := longForm{Type: v.Type, Source: v.Source, Target: v.Target} + if v.NoCopy { + lf.Volume = &vol{Nocopy: true} + } + return lf, nil +} diff --git a/internal/devenv/compose/types_test.go b/internal/devenv/compose/types_test.go new file mode 100644 index 000000000..0970e9ed5 --- /dev/null +++ b/internal/devenv/compose/types_test.go @@ -0,0 +1,61 @@ +package compose + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestVolumeMountShortAndLongForm(t *testing.T) { + short := VolumeMount{Short: "./config:/wp/config"} + sb, err := yaml.Marshal([]VolumeMount{short}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(sb), "- ./config:/wp/config") { + t.Fatalf("short form wrong:\n%s", sb) + } + + long := VolumeMount{ + Type: "volume", + Source: "devtools", + Target: "/dev-tools", + NoCopy: true, + } + lb, err := yaml.Marshal([]VolumeMount{long}) + if err != nil { + t.Fatal(err) + } + got := string(lb) + for _, want := range []string{"type: volume", "source: devtools", "target: /dev-tools", "nocopy: true"} { + if !strings.Contains(got, want) { + t.Fatalf("long form missing %q:\n%s", want, got) + } + } +} + +func TestProjectMarshalsDeterministically(t *testing.T) { + p := &Project{ + Name: "example", + Services: map[string]*Service{ + "memcached": { + Image: "memcached:1.6-alpine", + Command: "memcached -m 64", + Environment: map[string]string{ + "LANDO_NEEDS_EXEC": "1", + }, + }, + }, + } + out, err := yaml.Marshal(p) + if err != nil { + t.Fatalf("marshal: %v", err) + } + got := string(out) + for _, want := range []string{"name: example", "services:", "memcached:", "image: memcached:1.6-alpine", "command: memcached -m 64", "LANDO_NEEDS_EXEC:"} { + if !strings.Contains(got, want) { + t.Fatalf("marshaled compose missing %q:\n%s", want, got) + } + } +} diff --git a/internal/devenv/compose/view.go b/internal/devenv/compose/view.go new file mode 100644 index 000000000..55af59f08 --- /dev/null +++ b/internal/devenv/compose/view.go @@ -0,0 +1,202 @@ +package compose + +import ( + "encoding/json" + "strings" + + "github.com/Automattic/vip/internal/devenv/instancedata" +) + +const ( + // DefaultDomain is the domain NEW envs pin. Automattic owns vipdev.site and + // *.vipdev.site resolves to 127.0.0.1 publicly; the managed hosts block makes + // it work offline too. Legacy/migrated envs keep instancedata.LegacyDomain. + DefaultDomain = "vipdev.site" + // ProxyNetwork is the shared external network that the central Traefik proxy + // and each env's Traefik-routed edge services (nginx/phpmyadmin/mailpit) join + // so the proxy can reach them. Backend services must NOT join it: every env's + // compose registers the bare service name (e.g. `database`) as an alias on + // each network it joins, and those bare aliases collide across environments on + // this shared network, so Docker round-robin DNS would route one env's `wp` to + // another env's database (cross-env data bleed). Backends stay on ProjectNetwork. + ProxyNetwork = "vip-dev-env" + // ProjectNetwork is the per-environment network. Keyed "default", docker + // compose scopes it to `_default` (project == slug), so the bare + // `database`/`memcached`/etc. aliases resolve only within the env. This is the + // isolation Lando got from its per-app network (`_default`) while scoping + // the shared-bridge alias to `..internal`; plain compose can't + // suppress the bare alias on a shared network, so we keep backends off it. + ProjectNetwork = "default" +) + +// Options carries render-time inputs not stored in InstanceData. +type Options struct { + // Domain overrides DefaultDomain (per-env custom domain; Plan 3/5). + Domain string + // HostUID/HostGID feed the LANDO_HOST_USER_ID/GID env. Defaults "1000". + HostUID string + HostGID string + // Migrate, when true, declares data volumes external (Plan 4 migration). + Migrate bool + // ExternalVolumeNames maps logical volume name -> existing external name + // (only consulted when Migrate is true). + ExternalVolumeNames map[string]string +} + +// View is the fully-resolved, pure input the service/label builders consume. +type View struct { + SiteSlug string + WPTitle string + Domain string + + MultisiteEnabled bool + MultisiteSubdomain bool + + PHPImage string + DatabaseImage string + WordPressImage string + + Xdebug bool + XdebugConfig string + Cron bool + AutologinKey string + AdminPassword string + + PHPMyAdmin bool + Elasticsearch bool + Mailpit bool + Photon bool + + MuPluginsLocal bool + MuPluginsDir string + AppCodeLocal bool + AppCodeDir string + + HostUID string + HostGID string + + Migrate bool + ExternalVolumeNames map[string]string + // EnvVars are per-env user variables injected into the php service + // environment (Plan 5 envvar). Reserved keys win over user keys. + EnvVars map[string]string + // MigratedFromLando carries instancedata's marker into the info table + // (Go-only). Empty for envs never adopted from Lando. + MigratedFromLando string +} + +// NewView derives a View from instance data + options, applying the same +// defaults as preProcessInstanceData (dev-environment-core.ts:317-347). +func NewView(d *instancedata.InstanceData, opts Options) View { + v := View{ + SiteSlug: d.SiteSlug, + WPTitle: d.WPTitle, + Domain: firstNonEmpty(opts.Domain, DefaultDomain), + PHPImage: phpImage(d.PHP), + WordPressImage: "ghcr.io/automattic/vip-container-images/wordpress:" + wordpressTag(d), + Xdebug: d.Xdebug, + XdebugConfig: d.XdebugConfig, + Cron: d.Cron, + AutologinKey: d.AutologinKey, + AdminPassword: firstNonEmpty(d.AdminPassword, "password"), + PHPMyAdmin: d.PHPMyAdmin, + Elasticsearch: truthyRaw(d.Elasticsearch), + Mailpit: d.Mailpit, + Photon: d.Photon, + MuPluginsLocal: d.MuPlugins.Mode == "local", + MuPluginsDir: d.MuPlugins.Dir, + AppCodeLocal: d.AppCode.Mode == "local", + AppCodeDir: d.AppCode.Dir, + HostUID: firstNonEmpty(opts.HostUID, "1000"), + HostGID: firstNonEmpty(opts.HostGID, "1000"), + Migrate: opts.Migrate, + ExternalVolumeNames: opts.ExternalVolumeNames, + EnvVars: d.EnvVars, + MigratedFromLando: d.MigratedFromLando, + } + + v.MultisiteEnabled, v.MultisiteSubdomain = multisite(d.Multisite) + + if d.MariaDB != "" { + v.DatabaseImage = "mariadb:" + d.MariaDB + } else { + v.DatabaseImage = "mysql:8.4" + } + return v +} + +func firstNonEmpty(vals ...string) string { + for _, s := range vals { + if s != "" { + return s + } + } + return "" +} + +func wordpressTag(d *instancedata.InstanceData) string { + if d.WordPress.Tag != "" { + return d.WordPress.Tag + } + return "trunk" +} + +// phpFPMImagePrefix is the VIP php-fpm image repo; a bare version is appended. +const phpFPMImagePrefix = "ghcr.io/automattic/vip-container-images/php-fpm:" + +// defaultPHPImage is the recommended php-fpm image when none is specified — +// the first entry of Node's DEV_ENVIRONMENT_PHP_VERSIONS (8.2, recommended). +const defaultPHPImage = phpFPMImagePrefix + "8.2" + +// phpImage resolves the php-fpm image from instance-data's php field, mirroring +// Node DEV_ENVIRONMENT_PHP_VERSIONS resolution: empty -> recommended default; a +// bare version like "8.3" -> the matching php-fpm image; an explicit image +// reference (already containing "/" or ":") -> used verbatim. Resolving at +// render time matches how wordpressTag/DatabaseImage already default. +func phpImage(php string) string { + if php == "" { + return defaultPHPImage + } + if strings.ContainsAny(php, "/:") { + return php + } + return phpFPMImagePrefix + php +} + +// multisite interprets the bool|string union. bool true => enabled+subdomain +// (per the EJS `multisite === true || === 'subdomain'` subdomain branch); the +// string "subdomain" => enabled+subdomain; any other non-empty string => +// enabled (subdirectory). Mirrors the EJS `if (multisite)` gate. +func multisite(raw json.RawMessage) (enabled, subdomain bool) { + if len(raw) == 0 { + return false, false + } + var b bool + if err := json.Unmarshal(raw, &b); err == nil { + return b, b + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + s = strings.ToLower(s) + if s == "" { + return false, false + } + return true, s == "subdomain" + } + return false, false +} + +func truthyRaw(raw json.RawMessage) bool { + if len(raw) == 0 { + return false + } + var b bool + if err := json.Unmarshal(raw, &b); err == nil { + return b + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s != "" + } + return false +} diff --git a/internal/devenv/compose/view_test.go b/internal/devenv/compose/view_test.go new file mode 100644 index 000000000..2a3e45160 --- /dev/null +++ b/internal/devenv/compose/view_test.go @@ -0,0 +1,120 @@ +package compose + +import ( + "encoding/json" + "testing" + + "github.com/Automattic/vip/internal/devenv/instancedata" +) + +func TestNewViewInterpretsMultisiteAndDefaults(t *testing.T) { + data := &instancedata.InstanceData{ + SiteSlug: "example", + WPTitle: "Example", + Multisite: json.RawMessage("false"), + WordPress: instancedata.WordPressConfig{Mode: "image", Tag: "trunk"}, + MuPlugins: instancedata.ComponentConfig{Mode: "image"}, + AppCode: instancedata.ComponentConfig{Mode: "local", Dir: "/srv/example"}, + PHP: "ghcr.io/automattic/vip-container-images/php-fpm:8.2", + } + v := NewView(data, Options{}) + + if v.SiteSlug != "example" { + t.Fatalf("SiteSlug = %q", v.SiteSlug) + } + if v.Domain != DefaultDomain { + t.Fatalf("default Domain = %q, want DefaultDomain %q", v.Domain, DefaultDomain) + } + if v.MultisiteEnabled { + t.Fatalf("multisite should be disabled for false") + } + if v.AdminPassword != "password" { + t.Fatalf("default AdminPassword = %q, want password", v.AdminPassword) + } + if !v.AppCodeLocal || v.AppCodeDir != "/srv/example" { + t.Fatalf("appCode local/dir wrong: %+v", v) + } + if v.MuPluginsLocal { + t.Fatalf("muPlugins should be image mode") + } + if v.DatabaseImage != "mysql:8.4" { + t.Fatalf("default db image = %q, want mysql:8.4", v.DatabaseImage) + } +} + +func TestNewViewSubdomainMultisite(t *testing.T) { + data := &instancedata.InstanceData{ + SiteSlug: "ms", + Multisite: json.RawMessage(`"subdomain"`), + WordPress: instancedata.WordPressConfig{Mode: "image", Tag: "trunk"}, + MariaDB: "10.11", + } + v := NewView(data, Options{}) + if !v.MultisiteEnabled || !v.MultisiteSubdomain { + t.Fatalf("expected subdomain multisite enabled: %+v", v) + } + if v.DatabaseImage != "mariadb:10.11" { + t.Fatalf("mariadb image = %q", v.DatabaseImage) + } +} + +// TestNewViewBoolTrueMultisite locks the parity-critical branch: a bool `true` +// multisite must enable subdomain routing (EJS `multisite === true` => --subdomain). +func TestNewViewBoolTrueMultisite(t *testing.T) { + data := &instancedata.InstanceData{ + SiteSlug: "ms2", + Multisite: json.RawMessage("true"), + WordPress: instancedata.WordPressConfig{Mode: "image", Tag: "trunk"}, + } + v := NewView(data, Options{}) + if !v.MultisiteEnabled || !v.MultisiteSubdomain { + t.Fatalf("bool true multisite should be enabled+subdomain: %+v", v) + } +} + +func TestNewViewCopiesEnvVars(t *testing.T) { + d := &instancedata.InstanceData{SiteSlug: "e", Multisite: json.RawMessage("false"), EnvVars: map[string]string{"A": "1"}} + v := NewView(d, Options{}) + if v.EnvVars["A"] != "1" { + t.Fatalf("NewView did not copy EnvVars: %+v", v.EnvVars) + } +} + +// TestNewViewSubdirectoryMultisite: a non-subdomain string enables multisite +// but NOT subdomain routing. +func TestNewViewSubdirectoryMultisite(t *testing.T) { + data := &instancedata.InstanceData{ + SiteSlug: "ms3", + Multisite: json.RawMessage(`"subdirectory"`), + WordPress: instancedata.WordPressConfig{Mode: "image", Tag: "trunk"}, + } + v := NewView(data, Options{}) + if !v.MultisiteEnabled { + t.Fatalf("subdirectory multisite should be enabled: %+v", v) + } + if v.MultisiteSubdomain { + t.Fatalf("subdirectory multisite must NOT be subdomain: %+v", v) + } +} + +func TestDefaultDomainIsVipdevSite(t *testing.T) { + if DefaultDomain != "vipdev.site" { + t.Fatalf("DefaultDomain = %q, want vipdev.site", DefaultDomain) + } +} + +func TestNewViewResolvesPHPImage(t *testing.T) { + base := func(php string) *instancedata.InstanceData { + return &instancedata.InstanceData{SiteSlug: "e", Multisite: json.RawMessage("false"), PHP: php} + } + cases := []struct{ php, want string }{ + {"", "ghcr.io/automattic/vip-container-images/php-fpm:8.2"}, // empty -> recommended default + {"8.4", "ghcr.io/automattic/vip-container-images/php-fpm:8.4"}, // bare version -> mapped image + {"ghcr.io/automattic/vip-container-images/php-fpm:8.3", "ghcr.io/automattic/vip-container-images/php-fpm:8.3"}, // explicit image -> as-is + } + for _, c := range cases { + if got := NewView(base(c.php), Options{}).PHPImage; got != c.want { + t.Errorf("phpImage(%q) => %q, want %q", c.php, got, c.want) + } + } +} diff --git a/internal/devenv/devlog/devlog.go b/internal/devenv/devlog/devlog.go new file mode 100644 index 000000000..ee2941529 --- /dev/null +++ b/internal/devenv/devlog/devlog.go @@ -0,0 +1,219 @@ +// Package devlog owns the per-environment dev-env command log. Every +// docker/docker compose invocation tees its output here via Writer(), and the +// CLI's own dev-env diagnostics are logged through Logf, so VIP and Docker +// output interleave in one per-invocation timestamped file — the behavior +// Lando's winston logger + shell tee provided before. Each invocation opens a +// fresh file under the environment's own logs/ directory (Node parity: +// getDevEnvLogFile -> vip-dev-env--.log). +package devlog + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/Automattic/vip/internal/devenv/paths" +) + +const logName = "vip-dev-env" + +// Logger is a single open handle to one invocation's log file. Safe for +// concurrent writers (the tee from stdout and stderr run concurrently). +type Logger struct { + mu sync.Mutex + f *os.File + path string + tty io.Writer // where Finish() prints the log-path footer +} + +// Open creates the environment's logs/ directory and opens a fresh, +// per-invocation timestamped log file for appending. +func Open(slug string) (*Logger, error) { + dir := paths.EnvLogDir(slug) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + p := filepath.Join(dir, logFileName(slug, time.Now())) + f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return nil, err + } + return &Logger{f: f, path: p, tty: os.Stderr}, nil +} + +// logFileName builds vip-dev-env--.log, matching Node's +// getDevEnvLogFile (formatDevEnvLogSlug + formatDevEnvLogTimestamp). +func logFileName(slug string, t time.Time) string { + return fmt.Sprintf("%s-%s-%s.log", logName, formatLogSlug(slug), t.UTC().Format("20060102-150405")) +} + +var logSlugInvalid = regexp.MustCompile(`[^a-z0-9_-]+`) + +// formatLogSlug mirrors Node's formatDevEnvLogSlug: lowercase, replacing any +// run of disallowed characters with a single dash. An empty slug maps to "all". +func formatLogSlug(slug string) string { + if slug == "" { + return "all" + } + return logSlugInvalid.ReplaceAllString(strings.ToLower(slug), "-") +} + +// Path returns the log file path. +func (l *Logger) Path() string { return l.path } + +// Close closes the underlying file. +func (l *Logger) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + return l.f.Close() +} + +// writeLine writes one prefixed line (no trailing newline in msg). +func (l *Logger) writeLine(level, msg string) { + l.mu.Lock() + defer l.mu.Unlock() + ts := time.Now().UTC().Format("2006-01-02T15:04:05Z") + _, _ = fmt.Fprintf(l.f, "%s [%s] %s: %s\n", ts, logName, level, msg) +} + +// Logf writes a single diagnostic line at INFO level. +func (l *Logger) Logf(format string, args ...any) { + l.writeLine("INFO", fmt.Sprintf(format, args...)) +} + +// Writer returns an io.WriteCloser that splits input into lines and writes +// each complete line to the log with a prefix. A trailing partial line is +// held in a buffer and flushed when the writer is Closed — callers MUST +// Close the writer (after the subprocess exits) so a final non-newline- +// terminated line is not lost. +// +// Each returned writer is single-goroutine: its internal line buffer is not +// synchronized, so call Writer() once per concurrent stream (e.g. separate +// writers for a subprocess's stdout and stderr) rather than sharing one +// writer across goroutines. Writes to the underlying log file are serialized. +func (l *Logger) Writer() io.WriteCloser { + return &lineWriter{log: l, level: "INFO"} +} + +// DockerVersions holds the docker/compose versions shown in the banner. +type DockerVersions struct { + Engine, Compose, ComposePlugin, DockerBin, ComposeBin string +} + +// Banner is the diagnostic header written once at the top of a fresh log. +// Ports writeLogBanner (dev-environment-lando.ts:247-286); NODE is replaced +// by CLI/runtime since there is no Node runtime any more. +type Banner struct { + Command string + OS string + CLI string + Runtime string + Docker DockerVersions + RAMGB string + CPUs string +} + +// WriteBanner appends the banner only if the log file is currently empty, +// matching Lando's "write banner when size == 0" behavior. +func (l *Logger) WriteBanner(b Banner) error { + l.mu.Lock() + defer l.mu.Unlock() + + info, err := l.f.Stat() + if err != nil { + return err + } + if info.Size() > 0 { + return nil + } + + line := func(label, value string) string { + return fmt.Sprintf("%-18s %s\n", label, value) + } + var sb []byte + sb = append(sb, "=== VIP Dev Env Log ===\n"...) + sb = append(sb, line("COMMAND", b.Command)...) + sb = append(sb, line("OS", b.OS)...) + sb = append(sb, line("CLI", b.CLI)...) + sb = append(sb, line("RUNTIME", b.Runtime)...) + sb = append(sb, line("DOCKER ENGINE", b.Docker.Engine)...) + sb = append(sb, line("DOCKER COMPOSE", b.Docker.Compose)...) + sb = append(sb, line("COMPOSE PLUGIN", b.Docker.ComposePlugin)...) + sb = append(sb, line("DOCKER BIN", b.Docker.DockerBin)...) + sb = append(sb, line("COMPOSE BIN", b.Docker.ComposeBin)...) + sb = append(sb, line("RAM", b.RAMGB)...) + sb = append(sb, line("CPU", b.CPUs)...) + sb = append(sb, "===\n\n\n"...) + + _, err = l.f.Write(sb) + return err +} + +type lineWriter struct { + log *Logger + level string + buf bytes.Buffer +} + +func (w *lineWriter) Write(p []byte) (int, error) { + w.buf.Write(p) + for { + line, err := w.buf.ReadString('\n') + if err != nil { + // No newline yet: put the partial back and wait for more. + w.buf.Reset() + w.buf.WriteString(line) + break + } + w.log.writeLine(w.level, strings.TrimRight(line[:len(line)-1], "\r")) + } + return len(p), nil +} + +// Close flushes any buffered partial (non-newline-terminated) line to the +// log so trailing output is never dropped. +func (w *lineWriter) Close() error { + if w.buf.Len() > 0 { + w.log.writeLine(w.level, w.buf.String()) + w.buf.Reset() + } + return nil +} + +// SetFooterWriter overrides where Finish() writes (defaults to stderr). +// Used in tests; production passes os.Stderr. +func (l *Logger) SetFooterWriter(w io.Writer) { + l.mu.Lock() + defer l.mu.Unlock() + l.tty = w +} + +// Finish prints the "COMMAND LOG FILE " footer so users can find the +// combined log. Ports registerLogPathOutput (dev-environment-lando.ts:146-171). +func (l *Logger) Finish() { + l.mu.Lock() + defer l.mu.Unlock() + fmt.Fprintf(l.tty, "\n %-18s %s\n", "COMMAND LOG FILE", l.path) +} + +// loggerCtxKey is the private context key under which a session Logger is +// carried so the docker runner can pick it up without signature churn. +type loggerCtxKey struct{} + +// WithLogger returns a context carrying l, so newRunner can tee through it. +func WithLogger(ctx context.Context, l *Logger) context.Context { + return context.WithValue(ctx, loggerCtxKey{}, l) +} + +// FromContext returns the session Logger carried by ctx, or nil. +func FromContext(ctx context.Context) *Logger { + l, _ := ctx.Value(loggerCtxKey{}).(*Logger) + return l +} diff --git a/internal/devenv/devlog/devlog_test.go b/internal/devenv/devlog/devlog_test.go new file mode 100644 index 000000000..adbbc18f4 --- /dev/null +++ b/internal/devenv/devlog/devlog_test.go @@ -0,0 +1,112 @@ +package devlog + +import ( + "bytes" + "os" + "strings" + "testing" +) + +func TestWriterFlushesPartialLineOnClose(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + l, err := Open("testslug") + if err != nil { + t.Fatalf("Open: %v", err) + } + w := l.Writer() + w.Write([]byte("no newline at end")) + if err := w.Close(); err != nil { + t.Fatalf("Writer Close: %v", err) + } + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + b, err := os.ReadFile(l.Path()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), "no newline at end") { + t.Fatalf("partial line lost; log:\n%s", b) + } +} + +func TestWriterPrefixesCompleteLinesIntoLogFile(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + + l, err := Open("testslug") + if err != nil { + t.Fatalf("Open: %v", err) + } + w := l.Writer() + // Two writes that together form exactly two complete lines split across + // the write boundary ("hello\n" and "world\n"). No partial remains. + w.Write([]byte("hello\nwor")) + w.Write([]byte("ld\n")) + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + b, err := os.ReadFile(l.Path()) + if err != nil { + t.Fatal(err) + } + got := string(b) + if c := strings.Count(got, "[vip-dev-env] INFO:"); c != 2 { + t.Fatalf("expected 2 prefixed lines, got %d in:\n%s", c, got) + } + if !strings.Contains(got, "hello") || !strings.Contains(got, "world") { + t.Fatalf("log missing content:\n%s", got) + } +} + +func TestWriteBannerOnlyOnEmptyLog(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + + b := Banner{ + Command: "vip dev-env start", + OS: "darwin 25.5.0 arm64", + CLI: "4.0.0", + Runtime: "go", + Docker: DockerVersions{Engine: "27.0", Compose: "2.29", ComposePlugin: "2.29", DockerBin: "/usr/bin/docker", ComposeBin: "docker compose"}, + RAMGB: "16.0 GB", + CPUs: "10", + } + + l, _ := Open("testslug") + if err := l.WriteBanner(b); err != nil { + t.Fatalf("WriteBanner: %v", err) + } + // Second call must be a no-op because the file is no longer empty. + if err := l.WriteBanner(b); err != nil { + t.Fatalf("WriteBanner (2nd): %v", err) + } + l.Close() + + data, _ := os.ReadFile(l.Path()) + got := string(data) + if c := strings.Count(got, "=== VIP Dev Env Log ==="); c != 1 { + t.Fatalf("banner written %d times, want 1:\n%s", c, got) + } + for _, want := range []string{"COMMAND", "DOCKER ENGINE", "27.0", "vip dev-env start"} { + if !strings.Contains(got, want) { + t.Fatalf("banner missing %q:\n%s", want, got) + } + } +} + +func TestFinishPrintsLogPathFooter(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + l, _ := Open("testslug") + + var tty bytes.Buffer + l.SetFooterWriter(&tty) + l.Finish() + l.Close() + + if !strings.Contains(tty.String(), "COMMAND LOG FILE") { + t.Fatalf("footer missing label: %q", tty.String()) + } + if !strings.Contains(tty.String(), l.Path()) { + t.Fatalf("footer missing path %q in %q", l.Path(), tty.String()) + } +} diff --git a/internal/devenv/devterm/devterm.go b/internal/devenv/devterm/devterm.go new file mode 100644 index 000000000..9d99f0ab6 --- /dev/null +++ b/internal/devenv/devterm/devterm.go @@ -0,0 +1,65 @@ +// Package devterm runs an interactive child process attached to a PTY and tees +// the PTY master to both the terminal and the unified dev-env log (spec §7.3). +// It is the §C "sharp edge" isolated here and reused by exec + shell. +// +// This file holds the cross-platform core: argv handling, the TTY check +// (Interactive), and the non-interactive RunPiped path (plain pipes, no PTY). +// The interactive raw-mode PTY run lives in devterm_pty.go (built on every +// non-Windows platform); Windows gets devterm_stub.go because that path needs +// Unix-only primitives (creack/pty, SIGWINCH, raw-mode termios). +package devterm + +import ( + "context" + "errors" + "io" + "os" + "os/exec" + + "golang.org/x/term" +) + +// splitArgv splits a non-empty argv into the binary name and its arguments. +func splitArgv(argv []string) (string, []string) { + return argv[0], argv[1:] +} + +// safeSplit is splitArgv with an empty-argv guard, used by Run. +func safeSplit(argv []string) (string, []string, error) { + if len(argv) == 0 { + return "", nil, errors.New("devterm: empty argv") + } + name, rest := splitArgv(argv) + return name, rest, nil +} + +// Interactive reports whether stdin is a terminal. Callers use it to choose +// between the raw-mode PTY path (Run) and the plain-pipe path (RunPiped), and +// to decide whether to disable docker compose's default TTY allocation. This +// mirrors Node's dev-env, which gates interactivity on process.stdin.isTTY +// (vip-dev-env-shell.js / dev-environment-lando.ts landoShell). +func Interactive() bool { + return term.IsTerminal(int(os.Stdin.Fd())) +} + +// RunPiped runs argv with inherited stdin and the caller-provided stdout/stderr +// writers (which devexec wires to MultiWriter(os.Stdout, log) and +// MultiWriter(os.Stderr, log) so output both reaches the terminal and tees to +// the unified dev-env log). Unlike Run it allocates no PTY and touches no raw +// terminal state, so it works when stdout/stdin are pipes (e.g. +// `vip dev-env exec -- wp post list --format=json > out.json`) and on every +// platform, Windows included. When dir is non-empty the child runs there. +func RunPiped(ctx context.Context, dir string, argv []string, stdout, stderr io.Writer) error { + name, rest, err := safeSplit(argv) + if err != nil { + return err + } + cmd := exec.CommandContext(ctx, name, rest...) + if dir != "" { + cmd.Dir = dir + } + cmd.Stdin = os.Stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + return cmd.Run() +} diff --git a/internal/devenv/devterm/devterm_pty.go b/internal/devenv/devterm/devterm_pty.go new file mode 100644 index 000000000..5b0244756 --- /dev/null +++ b/internal/devenv/devterm/devterm_pty.go @@ -0,0 +1,58 @@ +//go:build !windows + +package devterm + +import ( + "context" + "io" + "os" + "os/exec" + "os/signal" + "syscall" + + "github.com/creack/pty" + "golang.org/x/term" +) + +// Run starts argv attached to a PTY, puts the host terminal in raw mode, copies +// stdin->pty and pty->MultiWriter(os.Stdout, logW), handles SIGWINCH, and +// restores the terminal on exit. The unified-log tee falls out of the +// MultiWriter. When dir is non-empty the child runs with that working directory +// (the env's materialized dir so `docker compose exec` finds its compose file). +// Built on every non-Windows platform; needs a controlling TTY at runtime +// (term.MakeRaw errors cleanly if stdin is not a terminal). +func Run(ctx context.Context, dir string, argv []string, logW io.Writer) error { + name, rest, err := safeSplit(argv) + if err != nil { + return err + } + cmd := exec.CommandContext(ctx, name, rest...) + if dir != "" { + cmd.Dir = dir + } + ptmx, err := pty.Start(cmd) + if err != nil { + return err + } + defer func() { _ = ptmx.Close() }() + + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGWINCH) + go func() { + for range ch { + _ = pty.InheritSize(os.Stdin, ptmx) + } + }() + ch <- syscall.SIGWINCH // initial sizing + defer signal.Stop(ch) + + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + return err + } + defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }() + + go func() { _, _ = io.Copy(ptmx, os.Stdin) }() + _, _ = io.Copy(io.MultiWriter(os.Stdout, logW), ptmx) + return cmd.Wait() +} diff --git a/internal/devenv/devterm/devterm_stub.go b/internal/devenv/devterm/devterm_stub.go new file mode 100644 index 000000000..3643ab167 --- /dev/null +++ b/internal/devenv/devterm/devterm_stub.go @@ -0,0 +1,20 @@ +//go:build windows + +package devterm + +import ( + "context" + "errors" + "io" +) + +// Run (stub) — the real raw-mode PTY implementation lives in devterm_pty.go and +// is built on every non-Windows platform. Windows is excluded because the PTY +// path depends on Unix-only primitives (creack/pty, SIGWINCH, raw-mode termios), +// so it returns a clear unsupported error here. +func Run(_ context.Context, _ string, argv []string, _ io.Writer) error { + if _, _, err := safeSplit(argv); err != nil { + return err + } + return errors.New("devterm: interactive exec/shell is not supported on Windows") +} diff --git a/internal/devenv/devterm/devterm_test.go b/internal/devenv/devterm/devterm_test.go new file mode 100644 index 000000000..d31a092c7 --- /dev/null +++ b/internal/devenv/devterm/devterm_test.go @@ -0,0 +1,78 @@ +package devterm + +import ( + "bytes" + "context" + "runtime" + "strings" + "testing" +) + +// TestRunPipedTeesStdout runs a harmless command and verifies stdout is written +// to the caller-provided stdout writer (devexec wires this to MultiWriter(os.Stdout, log)). +func TestRunPipedTeesStdout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires a POSIX shell (echo/sh)") + } + var out, errb bytes.Buffer + if err := RunPiped(context.Background(), "", []string{"echo", "hello-piped"}, &out, &errb); err != nil { + t.Fatalf("RunPiped: %v", err) + } + if got := strings.TrimSpace(out.String()); got != "hello-piped" { + t.Fatalf("stdout = %q, want hello-piped", got) + } +} + +// TestRunPipedTeesStderr verifies stderr goes to the stderr writer, not stdout. +func TestRunPipedTeesStderr(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires a POSIX shell (echo/sh)") + } + var out, errb bytes.Buffer + if err := RunPiped(context.Background(), "", []string{"sh", "-c", "echo oops 1>&2"}, &out, &errb); err != nil { + t.Fatalf("RunPiped: %v", err) + } + if out.Len() != 0 { + t.Fatalf("stdout = %q, want empty", out.String()) + } + if got := strings.TrimSpace(errb.String()); got != "oops" { + t.Fatalf("stderr = %q, want oops", got) + } +} + +// TestRunPipedPropagatesExit verifies a non-zero child exit surfaces as an error +// (so the CLI can set a non-zero exit code, like Node's process.exitCode = 1). +func TestRunPipedPropagatesExit(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires a POSIX shell (sh)") + } + var out, errb bytes.Buffer + if err := RunPiped(context.Background(), "", []string{"sh", "-c", "exit 3"}, &out, &errb); err == nil { + t.Fatal("expected error for non-zero exit") + } +} + +// TestRunPipedEmptyArgv guards the empty-argv path. +func TestRunPipedEmptyArgv(t *testing.T) { + var out, errb bytes.Buffer + if err := RunPiped(context.Background(), "", nil, &out, &errb); err == nil { + t.Fatal("expected error for empty argv") + } +} + +func TestSplitArgvForExec(t *testing.T) { + argv := []string{"docker", "compose", "-p", "x", "exec", "php", "sh"} + name, rest := splitArgv(argv) + if name != "docker" { + t.Fatalf("name = %q, want docker", name) + } + if len(rest) != 6 || rest[0] != "compose" || rest[5] != "sh" { + t.Fatalf("rest = %v", rest) + } +} + +func TestSplitArgvEmpty(t *testing.T) { + if _, _, err := safeSplit(nil); err == nil { + t.Fatal("expected error for empty argv") + } +} diff --git a/internal/devenv/dockercli/capture.go b/internal/devenv/dockercli/capture.go new file mode 100644 index 000000000..bd16e76f5 --- /dev/null +++ b/internal/devenv/dockercli/capture.go @@ -0,0 +1,84 @@ +package dockercli + +import ( + "bytes" + "context" + "encoding/json" + "os/exec" + "strings" + + "github.com/Automattic/vip/internal/devenv/paths" +) + +// ServiceState is the subset of `docker compose ps --format json` we consume. +type ServiceState struct { + Service string `json:"Service"` + State string `json:"State"` + ExitCode int `json:"ExitCode"` +} + +// parseComposePS handles both NDJSON (one object per line) and a JSON array, +// which different compose versions emit. Blank input yields no services. +func parseComposePS(b []byte) ([]ServiceState, error) { + t := bytes.TrimSpace(b) + if len(t) == 0 { + return nil, nil + } + if t[0] == '[' { + var arr []ServiceState + if err := json.Unmarshal(t, &arr); err != nil { + return nil, err + } + return arr, nil + } + var out []ServiceState + for _, line := range strings.Split(string(t), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var s ServiceState + if err := json.Unmarshal([]byte(line), &s); err != nil { + return nil, err + } + out = append(out, s) + } + return out, nil +} + +// DockerOut runs `docker ` capturing stdout (no tee). For read-only +// queries like `volume ls`. stderr is discarded; the error carries exit status. +func (r *Runner) DockerOut(ctx context.Context, args ...string) ([]byte, error) { + var buf bytes.Buffer + cmd := exec.CommandContext(ctx, r.dockerBin(), args...) + cmd.Stdout = &buf + err := cmd.Run() + return buf.Bytes(), err +} + +// ComposeOut runs a compose subcommand scoped to a project from the project's +// materialized directory (so compose finds its docker-compose.yml) and returns +// captured stdout. For read-only queries like `ps -q `. +func (r *Runner) ComposeOut(ctx context.Context, project string, args ...string) ([]byte, error) { + inv := r.composeInv() + var buf bytes.Buffer + cmd := exec.CommandContext(ctx, inv[0], r.ComposeArgs(project, args...)...) + cmd.Dir = paths.EnvironmentPath(project) + cmd.Stdout = &buf + err := cmd.Run() + return buf.Bytes(), err +} + +// ComposePS returns parsed service states for a project (captured, not tee'd). +// It runs from the project's materialized directory so compose finds its file. +func (r *Runner) ComposePS(ctx context.Context, project string) ([]ServiceState, error) { + inv := r.composeInv() + var buf bytes.Buffer + cmd := exec.CommandContext(ctx, inv[0], r.ComposeArgs(project, "ps", "--format", "json", "--all")...) + cmd.Dir = paths.EnvironmentPath(project) + cmd.Stdout = &buf + if err := cmd.Run(); err != nil { + return nil, err + } + return parseComposePS(buf.Bytes()) +} diff --git a/internal/devenv/dockercli/capture_test.go b/internal/devenv/dockercli/capture_test.go new file mode 100644 index 000000000..a81d04385 --- /dev/null +++ b/internal/devenv/dockercli/capture_test.go @@ -0,0 +1,33 @@ +package dockercli + +import "testing" + +func TestParseComposePSNDJSON(t *testing.T) { + in := []byte(`{"Service":"wordpress","State":"exited","ExitCode":0} +{"Service":"php","State":"running","ExitCode":0}`) + got, err := parseComposePS(in) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].Service != "wordpress" || got[0].State != "exited" || got[0].ExitCode != 0 { + t.Fatalf("bad parse: %+v", got) + } + if got[1].Service != "php" || got[1].State != "running" { + t.Fatalf("bad parse: %+v", got) + } +} + +func TestParseComposePSArray(t *testing.T) { + in := []byte(`[{"Service":"db","State":"running","ExitCode":0}]`) + got, err := parseComposePS(in) + if err != nil || len(got) != 1 || got[0].Service != "db" { + t.Fatalf("array parse failed: %+v err=%v", got, err) + } +} + +func TestParseComposePSEmpty(t *testing.T) { + got, err := parseComposePS([]byte(" \n")) + if err != nil || len(got) != 0 { + t.Fatalf("empty should yield no services: %+v err=%v", got, err) + } +} diff --git a/internal/devenv/dockercli/compose.go b/internal/devenv/dockercli/compose.go new file mode 100644 index 000000000..49a276410 --- /dev/null +++ b/internal/devenv/dockercli/compose.go @@ -0,0 +1,30 @@ +package dockercli + +import ( + "os/exec" +) + +// composeInvocation decides how to invoke Compose: the `docker compose` plugin +// (preferred) or the standalone `docker-compose` binary. look mirrors +// exec.LookPath; pluginOK reports whether ` compose version` works. +func composeInvocation(dockerBin string, look func(string) (string, error), pluginOK func() bool) []string { + if pluginOK() { + return []string{dockerBin, "compose"} + } + if _, err := look("docker-compose"); err == nil { + return []string{"docker-compose"} + } + return []string{dockerBin, "compose"} // default; exec surfaces the real error +} + +// composeInv caches the resolved invocation per runner. +func (r *Runner) composeInv() []string { + r.composeOnce.Do(func() { + r.composeCmd = composeInvocation(r.dockerBin(), + exec.LookPath, + func() bool { + return exec.Command(r.dockerBin(), "compose", "version").Run() == nil + }) + }) + return r.composeCmd +} diff --git a/internal/devenv/dockercli/compose_test.go b/internal/devenv/dockercli/compose_test.go new file mode 100644 index 000000000..18f0b0b66 --- /dev/null +++ b/internal/devenv/dockercli/compose_test.go @@ -0,0 +1,35 @@ +package dockercli + +import ( + "errors" + "testing" +) + +// errNotFound is a sentinel the lookPath stubs below return for an absent binary. +var errNotFound = errors.New("executable not found") + +func TestComposeInvocationPluginPreferred(t *testing.T) { + inv := composeInvocation("docker", func(string) (string, error) { return "/x", nil }, func() bool { return true }) + if len(inv) != 2 || inv[0] != "docker" || inv[1] != "compose" { + t.Fatalf("want [docker compose], got %v", inv) + } +} + +func TestComposeInvocationStandaloneFallback(t *testing.T) { + inv := composeInvocation("docker", func(name string) (string, error) { + if name == "docker-compose" { + return "/usr/local/bin/docker-compose", nil + } + return "", errNotFound + }, func() bool { return false }) + if len(inv) != 1 || inv[0] != "docker-compose" { + t.Fatalf("want [docker-compose], got %v", inv) + } +} + +func TestComposeInvocationDefaultsToPlugin(t *testing.T) { + inv := composeInvocation("docker", func(string) (string, error) { return "", errNotFound }, func() bool { return false }) + if len(inv) != 2 || inv[0] != "docker" || inv[1] != "compose" { + t.Fatalf("want [docker compose] default, got %v", inv) + } +} diff --git a/internal/devenv/dockercli/runner.go b/internal/devenv/dockercli/runner.go new file mode 100644 index 000000000..a03886a6b --- /dev/null +++ b/internal/devenv/dockercli/runner.go @@ -0,0 +1,214 @@ +package dockercli + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "os/exec" + "strings" + "sync" + + "github.com/Automattic/vip/internal/devenv/devlog" + "github.com/Automattic/vip/internal/devenv/paths" +) + +// Runner executes docker / docker compose commands, tee-ing child stdout and +// stderr to both the terminal and the unified log (spec §7.3). It is the Go +// replacement for Lando's Shell.sh tee + command/exit-code trace. +type Runner struct { + Log *devlog.Logger + Stdout io.Writer // defaults to os.Stdout + Stderr io.Writer // defaults to os.Stderr + DockerBin string // docker executable; defaults to "docker" + composeOnce sync.Once + composeCmd []string +} + +// lockedWriter wraps an io.Writer with a shared mutex pointer so multiple +// lockedWriter instances covering the same underlying writer (e.g. when +// Stdout and Stderr both point to the same bytes.Buffer) share one lock. +// os/exec drives cmd.Stdout and cmd.Stderr from separate goroutines, so +// without the lock concurrent writes to a non-goroutine-safe writer race. +type lockedWriter struct { + mu *sync.Mutex + w io.Writer +} + +func (lw *lockedWriter) Write(p []byte) (int, error) { + lw.mu.Lock() + defer lw.mu.Unlock() + return lw.w.Write(p) +} + +func (r *Runner) out() io.Writer { + if r.Stdout != nil { + return r.Stdout + } + return os.Stdout +} + +func (r *Runner) err() io.Writer { + if r.Stderr != nil { + return r.Stderr + } + return os.Stderr +} + +// dockerBin returns the configured docker binary or the default. +func (r *Runner) dockerBin() string { + if r.DockerBin != "" { + return r.DockerBin + } + return "docker" +} + +// ComposeArgs builds the argument list (minus the leading binary) for a +// compose invocation scoped to a project. For the plugin form the list begins +// with "compose"; for the standalone form it begins with "-p" directly. +func (r *Runner) ComposeArgs(project string, args ...string) []string { + inv := r.composeInv() + out := append([]string{}, inv[1:]...) // "compose" for plugin, nothing for standalone + out = append(out, "-p", project) + return append(out, args...) +} + +// ComposeArgv builds the FULL argv (including the leading docker/compose +// binary) for a compose invocation scoped to a project. Unlike ComposeArgs +// (which omits the binary because Runner.run supplies it), this is for callers +// that hand a complete argv to another exec mechanism — the PTY tee in +// internal/devenv/devterm for interactive exec/shell. +func (r *Runner) ComposeArgv(project string, args ...string) []string { + inv := r.composeInv() + out := append([]string{}, inv...) // binary (+ "compose" for the plugin form) + out = append(out, "-p", project) + return append(out, args...) +} + +// SetComposeCmdForTest pins the resolved compose invocation. Test-only seam so +// other packages can build deterministic argv without a real docker install. +func (r *Runner) SetComposeCmdForTest(inv []string) { + r.composeCmd = inv + r.composeOnce.Do(func() {}) +} + +// Docker runs `docker `. +func (r *Runner) Docker(ctx context.Context, args ...string) error { + return r.run(ctx, "", r.dockerBin(), args...) +} + +// Compose runs the resolved compose binary scoped to a project, executing from +// the project's materialized directory so docker compose finds its +// docker-compose.yml (default discovery) and resolves the relative bind-mount +// paths (./config, ./uploads, .env, ...) against that directory. +func (r *Runner) Compose(ctx context.Context, project string, args ...string) error { + inv := r.composeInv() + return r.run(ctx, paths.EnvironmentPath(project), inv[0], r.ComposeArgs(project, args...)...) +} + +// ComposeStdin runs a compose command scoped to a project with stdin streamed +// from r (used to pipe a SQL dump into `wp db-myloader --stream`). Output is +// tee'd like Compose. +func (r *Runner) ComposeStdin(ctx context.Context, project string, stdin io.Reader, args ...string) error { + inv := r.composeInv() + return r.runStdin(ctx, paths.EnvironmentPath(project), stdin, inv[0], r.ComposeArgs(project, args...)...) +} + +// Versions probes docker/compose versions for the log banner. Failures are +// reported as "unknown" rather than errors (ports getDockerVersions, +// dev-environment-lando.ts:197-242). Output is captured, not tee'd. +func (r *Runner) Versions(ctx context.Context) devlog.DockerVersions { + inv := r.composeInv() + v := devlog.DockerVersions{ + Engine: "unknown", Compose: "unknown", ComposePlugin: "unknown", + DockerBin: r.dockerBin(), ComposeBin: strings.Join(inv, " "), + } + var buf bytes.Buffer + cmd := exec.CommandContext(ctx, r.dockerBin(), "info", "--format", "{{.ServerVersion}}") + cmd.Stdout = &buf + if err := cmd.Run(); err == nil { + if s := strings.TrimSpace(buf.String()); s != "" { + v.Engine = s + } + } + buf.Reset() + cmd = exec.CommandContext(ctx, inv[0], append(append([]string{}, inv[1:]...), "version", "--short")...) + cmd.Stdout = &buf + if err := cmd.Run(); err == nil { + if s := strings.TrimSpace(buf.String()); s != "" { + v.Compose = s + v.ComposePlugin = s + } + } + return v +} + +// run executes a single command, tee-ing output to the terminal and the log +// and recording the command line + exit code. When dir is non-empty the child +// runs with that working directory (compose commands run from the env's +// materialized dir so docker compose finds its compose file). The tee writers +// are closed after the process exits so devlog flushes any buffered trailing +// partial line (docker output that ends without a newline). +func (r *Runner) run(ctx context.Context, dir, name string, args ...string) error { + return r.runStdin(ctx, dir, nil, name, args...) +} + +// runStdin is run() with an optional stdin source (used to stream a SQL dump +// into `docker compose exec -T … wp db-myloader --stream`). When stdin is nil +// it behaves exactly like run(). +func (r *Runner) runStdin(ctx context.Context, dir string, stdin io.Reader, name string, args ...string) error { + if r.Log != nil { + r.Log.Logf("running: %s %s", name, strings.Join(args, " ")) + } + + cmd := exec.CommandContext(ctx, name, args...) + if dir != "" { + cmd.Dir = dir + } + if stdin != nil { + cmd.Stdin = stdin + } + + // Wrap the terminal writers behind a shared mutex. os/exec drives + // cmd.Stdout and cmd.Stderr from separate goroutines; if both point to + // the same underlying writer (common in tests and when output is + // redirected) concurrent writes race. One shared mutex covers both. + termMu := &sync.Mutex{} + termOut := &lockedWriter{mu: termMu, w: r.out()} + termErr := &lockedWriter{mu: termMu, w: r.err()} + + var outTee, errTee io.WriteCloser + if r.Log != nil { + outTee = r.Log.Writer() + errTee = r.Log.Writer() + cmd.Stdout = io.MultiWriter(termOut, outTee) + cmd.Stderr = io.MultiWriter(termErr, errTee) + } else { + cmd.Stdout = termOut + cmd.Stderr = termErr + } + + runErr := cmd.Run() + + // Flush buffered trailing partial lines into the log BEFORE recording the + // exit code, so all command output precedes the "finished" line. + if outTee != nil { + _ = outTee.Close() + _ = errTee.Close() + } + + code := 0 + if runErr != nil { + var ee *exec.ExitError + if errors.As(runErr, &ee) { + code = ee.ExitCode() + } else { + code = -1 + } + } + if r.Log != nil { + r.Log.Logf("finished: %s, exit code %d", name, code) + } + return runErr +} diff --git a/internal/devenv/dockercli/runner_test.go b/internal/devenv/dockercli/runner_test.go new file mode 100644 index 000000000..7cdb149dd --- /dev/null +++ b/internal/devenv/dockercli/runner_test.go @@ -0,0 +1,157 @@ +package dockercli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/Automattic/vip/internal/devenv/devlog" +) + +// TestRunHonorsDir proves the child process executes in the working directory +// passed to run — the fix for "no configuration file provided" (compose must +// run from the env's materialized dir, not the CLI's CWD). +func TestRunHonorsDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires a POSIX shell (/bin/pwd, /bin/sh)") + } + dir := t.TempDir() + var out bytes.Buffer + r := &Runner{Stdout: &out, Stderr: &out} + if err := r.run(context.Background(), dir, "/bin/pwd"); err != nil { + t.Fatalf("run: %v", err) + } + got, err := filepath.EvalSymlinks(strings.TrimSpace(out.String())) + if err != nil { + t.Fatal(err) + } + want, _ := filepath.EvalSymlinks(dir) + if got != want { + t.Fatalf("run executed in %q, want %q", got, want) + } +} + +func TestRunTeesStdoutToTerminalAndLog(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires a POSIX shell (/bin/sh)") + } + t.Setenv("XDG_DATA_HOME", t.TempDir()) + l, err := devlog.Open("testslug") + if err != nil { + t.Fatal(err) + } + + var term bytes.Buffer + r := &Runner{Log: l, Stdout: &term, Stderr: &term} + + // Use /bin/sh so the test does not require docker to be installed. + if err := r.run(context.Background(), "", "/bin/sh", "-c", "echo out-line; echo err-line 1>&2"); err != nil { + t.Fatalf("run: %v", err) + } + l.Close() + + if !strings.Contains(term.String(), "out-line") || !strings.Contains(term.String(), "err-line") { + t.Fatalf("terminal capture missing output: %q", term.String()) + } + + logBytes, err := os.ReadFile(l.Path()) + if err != nil { + t.Fatal(err) + } + logStr := string(logBytes) + if !strings.Contains(logStr, "out-line") || !strings.Contains(logStr, "err-line") { + t.Fatalf("log missing tee'd output:\n%s", logStr) + } + if !strings.Contains(logStr, "running:") { + t.Fatalf("log missing command trace:\n%s", logStr) + } + if !strings.Contains(logStr, "exit code 0") { + t.Fatalf("log missing exit code:\n%s", logStr) + } +} + +// TestRunFlushesTrailingPartialLineToLog proves the Runner closes the tee +// writers after the subprocess exits, so a final line WITHOUT a trailing +// newline is still captured in the log (devlog.Writer() only flushes its +// buffered partial line on Close). +func TestRunFlushesTrailingPartialLineToLog(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires a POSIX shell (/bin/sh)") + } + t.Setenv("XDG_DATA_HOME", t.TempDir()) + l, err := devlog.Open("testslug") + if err != nil { + t.Fatal(err) + } + var term bytes.Buffer + r := &Runner{Log: l, Stdout: &term, Stderr: &term} + if err := r.run(context.Background(), "", "/bin/sh", "-c", "printf 'no-trailing-newline'"); err != nil { + t.Fatalf("run: %v", err) + } + l.Close() + logBytes, err := os.ReadFile(l.Path()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(logBytes), "no-trailing-newline") { + t.Fatalf("trailing partial line lost in log:\n%s", logBytes) + } +} + +func TestComposeArgsPrependsComposeSubcommandAndProject(t *testing.T) { + // Pin the compose invocation to the plugin form so the test is not + // sensitive to whether docker compose is installed on the host. + r := &Runner{} + r.composeCmd = []string{"docker", "compose"} + r.composeOnce.Do(func() {}) // mark as done so composeInv() uses the pinned value + + got := r.ComposeArgs("myproject", "up", "-d") + want := []string{"compose", "-p", "myproject", "up", "-d"} + if len(got) != len(want) { + t.Fatalf("ComposeArgs = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("ComposeArgs[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestComposeArgvIncludesBinaryAndProject(t *testing.T) { + r := &Runner{} + r.composeCmd = []string{"docker", "compose"} + r.composeOnce.Do(func() {}) + got := r.ComposeArgv("proj", "exec", "php", "sh") + want := []string{"docker", "compose", "-p", "proj", "exec", "php", "sh"} + if len(got) != len(want) { + t.Fatalf("ComposeArgv = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("ComposeArgv[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestVersionsDegradesGracefullyWhenDockerMissing(t *testing.T) { + // Pin the compose invocation to the plugin form using a nonexistent binary + // so both the engine probe AND the compose probe fail deterministically, + // regardless of whether docker-compose standalone is installed on the host. + r := &Runner{DockerBin: "definitely-not-a-real-docker-binary-xyz"} + r.composeCmd = []string{"definitely-not-a-real-docker-binary-xyz", "compose"} + r.composeOnce.Do(func() {}) // mark as done so composeInv() uses the pinned value + v := r.Versions(context.Background()) + if v.Engine != "unknown" || v.Compose != "unknown" || v.ComposePlugin != "unknown" { + t.Fatalf("expected unknown versions when docker is missing, got %+v", v) + } + if v.DockerBin != "definitely-not-a-real-docker-binary-xyz" { + t.Fatalf("DockerBin not reflected: %q", v.DockerBin) + } + if v.ComposeBin != "definitely-not-a-real-docker-binary-xyz compose" { + t.Fatalf("ComposeBin not as expected: %q", v.ComposeBin) + } +} diff --git a/internal/devenv/dockercli/socket.go b/internal/devenv/dockercli/socket.go new file mode 100644 index 000000000..e243cf118 --- /dev/null +++ b/internal/devenv/dockercli/socket.go @@ -0,0 +1,56 @@ +// Package dockercli drives the docker and docker compose CLIs (spec §4). +// "Purely Go" here means no Node/Lando — we still shell out to the docker +// binaries, which are already hard host requirements. +package dockercli + +import ( + "os" + "path/filepath" + "runtime" + "strings" +) + +// DockerSocket ports getDockerSocket (docker-utils.ts:45-82). On non-Windows +// it resolves a usable unix socket path, honoring a non-unix DOCKER_HOST +// verbatim. Returns "" (no error) when nothing usable is found. +// +// SIDE EFFECT (intentional, mirrors the Node helper): when a usable unix +// socket is discovered it also sets DOCKER_HOST=unix:// in the process +// environment so child `docker` invocations inherit it. Call once at startup. +func DockerSocket() (string, error) { + if runtime.GOOS == "windows" { + return "", nil + } + + possible := os.Getenv("DOCKER_HOST") + if possible != "" && !strings.HasPrefix(possible, "unix://") { + return possible, nil + } + + var candidates []string + if possible != "" { + // Strip leading unix:// (may have 1-3 slashes) and normalize to /path. + trimmed := strings.TrimLeft(strings.TrimPrefix(possible, "unix:"), "/") + candidates = append(candidates, "/"+trimmed) + } + home, _ := os.UserHomeDir() + candidates = append(candidates, + "/var/run/docker.sock", + "/run/docker.sock", + filepath.Join(home, ".docker", "run", "docker.sock"), + filepath.Join(home, ".colima", "default", "docker.sock"), + filepath.Join(home, ".orbstack", "run", "docker.sock"), + ) + + for _, p := range candidates { + info, err := os.Stat(p) + if err != nil { + continue + } + if info.Mode()&os.ModeSocket != 0 { + os.Setenv("DOCKER_HOST", "unix://"+p) + return p, nil + } + } + return "", nil +} diff --git a/internal/devenv/dockercli/socket_test.go b/internal/devenv/dockercli/socket_test.go new file mode 100644 index 000000000..678019925 --- /dev/null +++ b/internal/devenv/dockercli/socket_test.go @@ -0,0 +1,52 @@ +package dockercli + +import ( + "net" + "os" + "path/filepath" + "testing" +) + +func TestDockerSocketHonorsNonUnixDockerHost(t *testing.T) { + t.Setenv("DOCKER_HOST", "tcp://127.0.0.1:2375") + got, err := DockerSocket() + if err != nil { + t.Fatalf("DockerSocket: %v", err) + } + if got != "tcp://127.0.0.1:2375" { + t.Fatalf("got %q, want the tcp DOCKER_HOST passed through", got) + } +} + +func TestDockerSocketFindsUnixSocket(t *testing.T) { + // Use a short base dir under /tmp rather than t.TempDir(): macOS limits + // unix socket paths to ~104 bytes and t.TempDir() under $TMPDIR + // (/var/folders/...) overflows it, which would silently skip this test + // on the project's primary target platform. /tmp keeps the path short on + // both macOS and Linux so the discovery + slash-normalization logic is + // actually exercised. + dir, err := os.MkdirTemp("/tmp", "ds") + if err != nil { + t.Skipf("cannot create short temp dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + + sockPath := filepath.Join(dir, "d.sock") + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Skipf("cannot create unix socket: %v", err) + } + defer ln.Close() + + t.Setenv("DOCKER_HOST", "unix://"+sockPath) + got, err := DockerSocket() + if err != nil { + t.Fatalf("DockerSocket: %v", err) + } + if got != sockPath { + t.Fatalf("got %q, want %q", got, sockPath) + } + if _, err := os.Stat(sockPath); err != nil { + t.Fatalf("socket should exist: %v", err) + } +} diff --git a/internal/devenv/e2esafety/gate_wiring_test.go b/internal/devenv/e2esafety/gate_wiring_test.go new file mode 100644 index 000000000..051a050ad --- /dev/null +++ b/internal/devenv/e2esafety/gate_wiring_test.go @@ -0,0 +1,90 @@ +package e2esafety + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestDestructivePackagesHavePackageLevelGate(t *testing.T) { + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(currentFile), "..", "..", "..")) + for _, rel := range []string{ + "internal/devenv/e2e_gate_test.go", + "internal/devenv/hostops/e2e_gate_test.go", + "cmd/vip-next/commands/devenv_e2e_gate_test.go", + } { + b, err := os.ReadFile(filepath.Join(repoRoot, rel)) + if err != nil { + t.Errorf("%s: %v", rel, err) + continue + } + source := string(b) + if !strings.Contains(source, "func TestMain(m *testing.M)") { + t.Errorf("%s has no package-level TestMain", rel) + } + if !strings.Contains(source, "e2esafety.Skip(os.Getenv, os.Stdout)") { + t.Errorf("%s does not invoke the runtime opt-in gate", rel) + } + } +} + +func TestDocumentationOnlyCommandGatesCannotReportPass(t *testing.T) { + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(currentFile), "..", "..", "..")) + path := filepath.Join(repoRoot, "cmd/vip-next/commands/devenv_e2e_test.go") + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + source := string(b) + if strings.Contains(source, "t.Log(") { + t.Fatal("documentation-only devenv_e2e functions must skip, not log and pass") + } + if got := strings.Count(source, "t.Skip("); got != 4 { + t.Fatalf("documentation-only skip count = %d, want 4", got) + } +} + +func TestTaggedSuitesRequireCleanStateAndIdentityCheckedCleanup(t *testing.T) { + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(currentFile), "..", "..", "..")) + + hostopsSource, err := os.ReadFile(filepath.Join(repoRoot, "internal/devenv/hostops/e2e_test.go")) + if err != nil { + t.Fatal(err) + } + hostops := string(hostopsSource) + for _, required := range []string{"before.RequireClean()", "e2esafety.CanRemove", "teardownOwned"} { + if !strings.Contains(hostops, required) { + t.Errorf("hostops e2e is missing %q", required) + } + } + for _, forbidden := range []string{"func preclean(", "proxy.Cleanup(ctx, r)"} { + if strings.Contains(hostops, forbidden) { + t.Errorf("hostops e2e still contains unsafe cleanup %q", forbidden) + } + } + + lifecycleSource, err := os.ReadFile(filepath.Join(repoRoot, "internal/devenv/e2e_test.go")) + if err != nil { + t.Fatal(err) + } + lifecycle := string(lifecycleSource) + for _, required := range []string{"before.RequireClean()", "e2esafety.AllOwnedMatch", "cleanupLifecycleE2E"} { + if !strings.Contains(lifecycle, required) { + t.Errorf("lifecycle e2e is missing %q", required) + } + } +} diff --git a/internal/devenv/e2esafety/safety.go b/internal/devenv/e2esafety/safety.go new file mode 100644 index 000000000..b39bff4f4 --- /dev/null +++ b/internal/devenv/e2esafety/safety.go @@ -0,0 +1,52 @@ +// Package e2esafety contains side-effect-free policy for destructive tagged tests. +package e2esafety + +import ( + "fmt" + "io" + "sort" + "strings" +) + +const GateMessage = "SKIP devenv_e2e: set VIP_DEVENV_E2E=1 to permit Docker, certificate, and hosts changes" + +func Enabled(getenv func(string) string) bool { + return getenv("VIP_DEVENV_E2E") == "1" +} + +func Skip(getenv func(string) string, out io.Writer) bool { + if Enabled(getenv) { + return false + } + fmt.Fprintln(out, GateMessage) + return true +} + +type Snapshot map[string]string + +func (s Snapshot) RequireClean() error { + var existing []string + for name, identity := range s { + if identity != "" { + existing = append(existing, name) + } + } + if len(existing) == 0 { + return nil + } + sort.Strings(existing) + return fmt.Errorf("devenv_e2e refuses to modify existing shared state: %s; clean or isolate it manually", strings.Join(existing, ", ")) +} + +func CanRemove(created, current string) bool { + return created != "" && current == created +} + +func AllOwnedMatch(owned, current Snapshot) bool { + for name, created := range owned { + if created != "" && !CanRemove(created, current[name]) { + return false + } + } + return true +} diff --git a/internal/devenv/e2esafety/safety_test.go b/internal/devenv/e2esafety/safety_test.go new file mode 100644 index 000000000..f886e0651 --- /dev/null +++ b/internal/devenv/e2esafety/safety_test.go @@ -0,0 +1,71 @@ +package e2esafety + +import ( + "bytes" + "strings" + "testing" +) + +func TestEnabledRequiresExactOptIn(t *testing.T) { + for _, tc := range []struct { + value string + want bool + }{{"", false}, {"0", false}, {"true", false}, {"1", true}} { + got := Enabled(func(string) string { return tc.value }) + if got != tc.want { + t.Errorf("Enabled(%q) = %v, want %v", tc.value, got, tc.want) + } + } +} + +func TestSkipPrintsExplicitMessage(t *testing.T) { + var out bytes.Buffer + if !Skip(func(string) string { return "" }, &out) { + t.Fatal("Skip must stop the package when opt-in is absent") + } + if !strings.Contains(out.String(), "VIP_DEVENV_E2E=1") { + t.Fatalf("skip message = %q", out.String()) + } +} + +func TestRequireCleanListsExistingResources(t *testing.T) { + s := Snapshot{ + "proxy-container": "container-id", + "managed-hosts": "hosts-sha256", + } + err := s.RequireClean() + if err == nil || !strings.Contains(err.Error(), "managed-hosts, proxy-container") { + t.Fatalf("RequireClean error = %v", err) + } +} + +func TestRequireCleanAllowsEmptySnapshot(t *testing.T) { + if err := (Snapshot{}).RequireClean(); err != nil { + t.Fatalf("empty snapshot must be clean: %v", err) + } +} + +func TestCanRemoveRequiresExactCreatedIdentity(t *testing.T) { + for _, tc := range []struct { + created string + current string + want bool + }{{"", "x", false}, {"x", "", false}, {"x", "replacement", false}, {"x", "x", true}} { + if got := CanRemove(tc.created, tc.current); got != tc.want { + t.Errorf("CanRemove(%q, %q) = %v, want %v", tc.created, tc.current, got, tc.want) + } + } +} + +func TestAllOwnedMatchRejectsReplacementAndMissingResources(t *testing.T) { + owned := Snapshot{"container": "container-1", "network": "network-1"} + if !AllOwnedMatch(owned, Snapshot{"container": "container-1", "network": "network-1"}) { + t.Fatal("exact identities must match") + } + if AllOwnedMatch(owned, Snapshot{"container": "container-2", "network": "network-1"}) { + t.Fatal("replacement container must not match") + } + if AllOwnedMatch(owned, Snapshot{"container": "container-1"}) { + t.Fatal("missing owned network must not match") + } +} diff --git a/internal/devenv/instancedata/instancedata.go b/internal/devenv/instancedata/instancedata.go new file mode 100644 index 000000000..ae34adff7 --- /dev/null +++ b/internal/devenv/instancedata/instancedata.go @@ -0,0 +1,239 @@ +// Package instancedata reads and writes a dev environment's +// instance_data.json. Ports the data layer of dev-environment-core.ts +// (readEnvironmentData / writeEnvironmentData / getAllEnvironmentNames / +// doesEnvironmentExist). Unknown keys are preserved losslessly so files +// written by older/newer CLIs survive a round-trip (spec §10). +package instancedata + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/Automattic/vip/internal/devenv/paths" +) + +// ComponentConfig mirrors types.ts ComponentConfig. +type ComponentConfig struct { + Mode string `json:"mode"` + Dir string `json:"dir,omitempty"` + Image string `json:"image,omitempty"` + Tag string `json:"tag,omitempty"` +} + +// WordPressConfig mirrors types.ts WordPressConfig. +type WordPressConfig struct { + Mode string `json:"mode"` + Tag string `json:"tag"` + Ref string `json:"ref,omitempty"` + DoNotUpgrade bool `json:"doNotUpgrade,omitempty"` +} + +// InstanceData mirrors types.ts InstanceData. multisite and elasticsearch +// are JS union types (bool|string), kept as json.RawMessage to round-trip +// faithfully; typed accessors are added in a later plan when consumers +// need them. Extra holds every key not modeled above, preserved verbatim. +type InstanceData struct { + SiteSlug string `json:"siteSlug"` + WPTitle string `json:"wpTitle"` + // Multisite is a JS union (bool|string). NOTE for environment-creation + // code (Plan 4): a nil value serializes as `"multisite": null`, which + // differs from the Node CLI (it always writes `false` or a string). + // Creators MUST set this explicitly (e.g. json.RawMessage("false")) to + // match Node output; reads round-trip whatever was on disk. + Multisite json.RawMessage `json:"multisite"` + WordPress WordPressConfig `json:"wordpress"` + MuPlugins ComponentConfig `json:"muPlugins"` + AppCode ComponentConfig `json:"appCode"` + MediaRedirectDomain string `json:"mediaRedirectDomain"` + PHPMyAdmin bool `json:"phpmyadmin"` + Xdebug bool `json:"xdebug"` + XdebugConfig string `json:"xdebugConfig,omitempty"` + MariaDB string `json:"mariadb,omitempty"` + PHP string `json:"php"` + Elasticsearch json.RawMessage `json:"elasticsearch,omitempty"` + Mailpit bool `json:"mailpit"` + Photon bool `json:"photon"` + Cron bool `json:"cron"` + PullAfter *int64 `json:"pullAfter,omitempty"` + AutologinKey string `json:"autologinKey,omitempty"` + AdminPassword string `json:"adminPassword,omitempty"` + Version string `json:"version,omitempty"` + Overrides string `json:"overrides,omitempty"` + // MigratedFromLando is an RFC3339 timestamp stamped the first time this env + // was adopted from a pre-existing Lando environment (Go-only; surfaced in + // `dev-env info`). Empty means never adopted. + MigratedFromLando string `json:"migratedFromLando,omitempty"` + // Domain is the per-env domain. New envs pin it explicitly at create + // (compose.DefaultDomain, "vipdev.site", unless `create --domain` overrides); + // an empty value marks a pre-switch/legacy env and is backfilled to + // LegacyDomain ("vipdev.lndo.site") on read. Consumed by compose.Options.Domain. + Domain string `json:"domain,omitempty"` + // ExternalVolumes maps a logical volume name to an existing (Lando) volume + // name; non-empty marks the env as migrated (Plan 4 §D). Declared external + // in the rendered compose so a destroy never deletes the original data. + ExternalVolumes map[string]string `json:"externalVolumes,omitempty"` + // EnvVars holds per-env user variables (Plan 5 `dev-env envvar`). Node + // stores these in the env's .env file; the Go port keeps them here in + // instance_data.json because Materialize owns/overwrites .env on every + // Start/Rebuild. They are injected into the php service environment on + // materialize (compose.View.EnvVars). + EnvVars map[string]string `json:"envVars,omitempty"` + + // Extra carries unmodeled keys verbatim for lossless round-trip. + Extra map[string]json.RawMessage `json:"-"` +} + +// LegacyDomain is the domain used by envs created before the vipdev.site switch +// (and Lando-migrated envs). An env whose stored Domain is empty predates the +// switch, so it is backfilled to this value on read — keeping its DB siteurl +// (which references *.vipdev.lndo.site) valid. New envs pin compose.DefaultDomain +// explicitly at create time, so they are never empty and never backfilled. +const LegacyDomain = "vipdev.lndo.site" + +// knownKeys is the set of JSON keys modeled by InstanceData's fields. +var knownKeys = map[string]bool{ + "siteSlug": true, "wpTitle": true, "multisite": true, "wordpress": true, + "muPlugins": true, "appCode": true, "mediaRedirectDomain": true, + "phpmyadmin": true, "xdebug": true, "xdebugConfig": true, "mariadb": true, + "php": true, "elasticsearch": true, "mailpit": true, "photon": true, + "cron": true, "pullAfter": true, "autologinKey": true, "adminPassword": true, + "version": true, "overrides": true, + "domain": true, "externalVolumes": true, "envVars": true, + "migratedFromLando": true, +} + +func parse(b []byte) (*InstanceData, error) { + d := &InstanceData{} + if err := json.Unmarshal(b, d); err != nil { + return nil, err + } + + var all map[string]json.RawMessage + if err := json.Unmarshal(b, &all); err != nil { + return nil, err + } + d.Extra = map[string]json.RawMessage{} + for k, v := range all { + if !knownKeys[k] { + d.Extra[k] = v + } + } + + applyBackcompat(d) + return d, nil +} + +// serialize merges modeled fields over the preserved unknown keys and +// emits 2-space-indented JSON (matching Node's JSON.stringify(data, null, 2) +// indentation; key ordering may differ, which is acceptable per spec §10). +func serialize(d *InstanceData) ([]byte, error) { + knownBytes, err := json.Marshal(d) + if err != nil { + return nil, err + } + var known map[string]json.RawMessage + if err := json.Unmarshal(knownBytes, &known); err != nil { + return nil, err + } + + merged := make(map[string]json.RawMessage, len(d.Extra)+len(known)) + for k, v := range d.Extra { + merged[k] = v + } + for k, v := range known { + merged[k] = v + } + return json.MarshalIndent(merged, "", " ") +} + +// applyBackcompat ports the BACKWARDS COMPATIBILITY section of +// readEnvironmentData (dev-environment-core.ts:558-575). +func applyBackcompat(d *InstanceData) { + // enterpriseSearchEnabled / elasticsearchEnabled -> elasticsearch + for _, legacy := range []string{"enterpriseSearchEnabled", "elasticsearchEnabled"} { + if v, ok := d.Extra[legacy]; ok && isTruthyJSON(v) { + d.Elasticsearch = json.RawMessage("true") + } + } + // clientCode -> appCode + if v, ok := d.Extra["clientCode"]; ok { + var cc ComponentConfig + if err := json.Unmarshal(v, &cc); err == nil { + d.AppCode = cc + } + } + // Envs created before the vipdev.site switch stored no domain; pin them to the + // legacy domain so they keep resolving to their original *.vipdev.lndo.site host. + if d.Domain == "" { + d.Domain = LegacyDomain + } +} + +func isTruthyJSON(v json.RawMessage) bool { + var b bool + if err := json.Unmarshal(v, &b); err == nil { + return b + } + var s string + if err := json.Unmarshal(v, &s); err == nil { + return s != "" + } + return false +} + +const instanceDataFileName = "instance_data.json" + +// Read loads and migrates an environment's instance data. Error messages +// mirror readEnvironmentData (dev-environment-core.ts:529-578). +func Read(slug string) (*InstanceData, error) { + target := filepath.Join(paths.EnvironmentPath(slug), instanceDataFileName) + b, err := os.ReadFile(target) + if err != nil { + return nil, fmt.Errorf("There was an error reading file %q: %s.", target, err) + } + d, err := parse(b) + if err != nil { + return nil, fmt.Errorf("There was an error parsing file %q: %s. You may need to recreate the environment.", target, err) + } + return d, nil +} + +// Write serializes instance data to disk, creating the env directory if +// needed. Ports writeEnvironmentData (2-space indent). +func Write(slug string, d *InstanceData) error { + dir := paths.EnvironmentPath(slug) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + out, err := serialize(d) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, instanceDataFileName), out, 0o644) +} + +// Exists reports whether an environment's instance_data.json is a file. +// Ports doesEnvironmentExist (dev-environment-core.ts:518-527). +func Exists(slug string) bool { + info, err := os.Stat(filepath.Join(paths.EnvironmentPath(slug), instanceDataFileName)) + return err == nil && info.Mode().IsRegular() +} + +// AllNames lists environment directory names under the dev-env base dir. +// Ports getAllEnvironmentNames (dev-environment-core.ts:705-723): only +// directories count; a missing base dir yields an empty slice. +func AllNames() []string { + entries, err := os.ReadDir(paths.DevEnvBase()) + if err != nil { + return nil + } + var names []string + for _, e := range entries { + if e.IsDir() { + names = append(names, e.Name()) + } + } + return names +} diff --git a/internal/devenv/instancedata/instancedata_test.go b/internal/devenv/instancedata/instancedata_test.go new file mode 100644 index 000000000..d1fa7deca --- /dev/null +++ b/internal/devenv/instancedata/instancedata_test.go @@ -0,0 +1,281 @@ +package instancedata + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Automattic/vip/internal/devenv/paths" +) + +func TestMigratedFromLandoRoundTrips(t *testing.T) { + in := []byte(`{"siteSlug":"foo","wpTitle":"Foo","multisite":false,"php":"8.2","migratedFromLando":"2026-07-10T00:00:00Z"}`) + d, err := parse(in) + if err != nil { + t.Fatal(err) + } + if d.MigratedFromLando != "2026-07-10T00:00:00Z" { + t.Fatalf("want marker parsed, got %q", d.MigratedFromLando) + } + out, err := serialize(d) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), `"migratedFromLando": "2026-07-10T00:00:00Z"`) { + t.Fatalf("marker not serialized: %s", out) + } +} + +func TestWriteThenReadRoundTrips(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + + in := &InstanceData{ + SiteSlug: "rt", + WPTitle: "Round Trip", + Multisite: json.RawMessage("false"), + WordPress: WordPressConfig{Mode: "image", Tag: "trunk"}, + MuPlugins: ComponentConfig{Mode: "image"}, + AppCode: ComponentConfig{Mode: "local", Dir: "/srv/rt"}, + PHP: "php:8.2", + Extra: map[string]json.RawMessage{"keepMe": json.RawMessage(`"yes"`)}, + } + if err := Write("rt", in); err != nil { + t.Fatalf("Write: %v", err) + } + if !Exists("rt") { + t.Fatalf("Exists(rt) = false after Write") + } + + out, err := Read("rt") + if err != nil { + t.Fatalf("Read: %v", err) + } + if out.SiteSlug != "rt" || out.WPTitle != "Round Trip" { + t.Fatalf("round-trip mismatch: %+v", out) + } + if string(out.Extra["keepMe"]) != `"yes"` { + t.Fatalf("Extra not preserved: %q", out.Extra["keepMe"]) + } +} + +func TestReadMissingFileReturnsError(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + _, err := Read("nope") + if err == nil { + t.Fatal("expected error reading missing env") + } +} + +func TestExistsFalseForMissing(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + if Exists("ghost") { + t.Fatal("Exists(ghost) = true for missing env") + } +} + +func TestParseSerializePreservesUnknownKeys(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "unknown_keys.json")) + if err != nil { + t.Fatal(err) + } + + d, err := parse(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + if d.SiteSlug != "example" || d.WPTitle != "Example Dev" { + t.Fatalf("known fields not parsed: %+v", d) + } + if !d.PHPMyAdmin { + t.Fatalf("phpmyadmin should be true") + } + if _, ok := d.Extra["futureKeyWeDoNotModel"]; !ok { + t.Fatalf("unknown key futureKeyWeDoNotModel not captured in Extra") + } + + out, err := serialize(d) + if err != nil { + t.Fatalf("serialize: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("re-parse: %v", err) + } + if _, ok := got["futureKeyWeDoNotModel"]; !ok { + t.Fatalf("unknown key lost on serialize") + } + if got["anotherUnknown"] != "keep-me" { + t.Fatalf("unknown scalar lost: %v", got["anotherUnknown"]) + } + if got["siteSlug"] != "example" { + t.Fatalf("known key lost: %v", got["siteSlug"]) + } +} + +func TestParseAppliesBackcompatMigrations(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "legacy_keys.json")) + if err != nil { + t.Fatal(err) + } + d, err := parse(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + + if string(d.Elasticsearch) != "true" { + t.Fatalf("enterpriseSearchEnabled should migrate to elasticsearch=true, got %q", d.Elasticsearch) + } + if d.AppCode.Mode != "local" || d.AppCode.Dir != "/srv/legacy" { + t.Fatalf("clientCode should migrate to appCode, got %+v", d.AppCode) + } +} + +// TestParseMigratesElasticsearchEnabledAlias covers the second legacy +// elasticsearch alias (elasticsearchEnabled), which the fixture-based test +// above does not exercise. applyBackcompat treats both enterpriseSearchEnabled +// and elasticsearchEnabled as inputs (dev-environment-core.ts:565-568). +func TestParseMigratesElasticsearchEnabledAlias(t *testing.T) { + in := []byte(`{ + "siteSlug": "es", + "wpTitle": "ES", + "multisite": false, + "wordpress": { "mode": "image", "tag": "trunk" }, + "muPlugins": { "mode": "image" }, + "appCode": { "mode": "image" }, + "mediaRedirectDomain": "", + "phpmyadmin": false, + "xdebug": false, + "php": "php:8.2", + "mailpit": false, + "photon": false, + "cron": false, + "elasticsearchEnabled": true + }`) + d, err := parse(in) + if err != nil { + t.Fatalf("parse: %v", err) + } + if string(d.Elasticsearch) != "true" { + t.Fatalf("elasticsearchEnabled should migrate to elasticsearch=true, got %q", d.Elasticsearch) + } +} + +// TestKnownKeysMatchStructTags machine-verifies the invariant that every +// modeled struct json tag is listed in knownKeys (and vice versa). If a +// field is added to InstanceData but not to knownKeys, parse() would put +// its key into Extra AND serialize() would also write it from the struct, +// double-writing the key — silent corruption. This test catches that drift. +func TestKnownKeysMatchStructTags(t *testing.T) { + rt := reflect.TypeOf(InstanceData{}) + + tagNames := map[string]bool{} + for i := 0; i < rt.NumField(); i++ { + tag := rt.Field(i).Tag.Get("json") + if tag == "" || tag == "-" { + continue + } + name := strings.Split(tag, ",")[0] + if name == "" { + continue + } + tagNames[name] = true + if !knownKeys[name] { + t.Errorf("struct field %s has json key %q missing from knownKeys (would be double-written on round-trip)", rt.Field(i).Name, name) + } + } + + for k := range knownKeys { + if !tagNames[k] { + t.Errorf("knownKeys has %q with no corresponding struct json tag", k) + } + } +} + +func TestDomainAndExternalVolumesRoundTrip(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + in := &InstanceData{ + SiteSlug: "example", + Multisite: json.RawMessage("false"), + Domain: "mysite.test", + ExternalVolumes: map[string]string{"database_data": "landoproj_database_data"}, + } + if err := Write("example", in); err != nil { + t.Fatal(err) + } + got, err := Read("example") + if err != nil { + t.Fatal(err) + } + if got.Domain != "mysite.test" { + t.Fatalf("domain lost: %q", got.Domain) + } + if got.ExternalVolumes["database_data"] != "landoproj_database_data" { + t.Fatalf("external volumes lost: %+v", got.ExternalVolumes) + } +} + +func TestEnvVarsRoundTrip(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + d := &InstanceData{ + SiteSlug: "evs", + Multisite: json.RawMessage("false"), + EnvVars: map[string]string{"MY_VAR": "hello", "OTHER": "x"}, + } + if err := Write("evs", d); err != nil { + t.Fatal(err) + } + got, err := Read("evs") + if err != nil { + t.Fatal(err) + } + if got.EnvVars["MY_VAR"] != "hello" || got.EnvVars["OTHER"] != "x" { + t.Fatalf("EnvVars did not round-trip: %+v", got.EnvVars) + } +} + +func TestEmptyDomainBackfilledToLegacy(t *testing.T) { + d := &InstanceData{} // Domain == "" + applyBackcompat(d) + if d.Domain != LegacyDomain { + t.Fatalf("empty Domain = %q, want LegacyDomain %q", d.Domain, LegacyDomain) + } + d2 := &InstanceData{Domain: "vipdev.site"} + applyBackcompat(d2) + if d2.Domain != "vipdev.site" { + t.Fatalf("non-empty Domain must be left alone, got %q", d2.Domain) + } +} + +func TestAllNamesListsEnvironmentDirectories(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + + // No base dir yet -> empty, no error. + if names := AllNames(); len(names) != 0 { + t.Fatalf("expected no envs, got %v", names) + } + + for _, slug := range []string{"alpha", "beta"} { + if err := Write(slug, &InstanceData{SiteSlug: slug, Multisite: json.RawMessage("false")}); err != nil { + t.Fatal(err) + } + } + // A stray file (not a directory) must be ignored. + if err := os.WriteFile(filepath.Join(paths.DevEnvBase(), "stray.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + got := AllNames() + want := map[string]bool{"alpha": true, "beta": true} + if len(got) != 2 { + t.Fatalf("AllNames() = %v, want alpha+beta only", got) + } + for _, n := range got { + if !want[n] { + t.Fatalf("unexpected env name %q in %v", n, got) + } + } +} diff --git a/internal/devenv/instancedata/testdata/legacy_keys.json b/internal/devenv/instancedata/testdata/legacy_keys.json new file mode 100644 index 000000000..9e545824c --- /dev/null +++ b/internal/devenv/instancedata/testdata/legacy_keys.json @@ -0,0 +1,16 @@ +{ + "siteSlug": "legacy", + "wpTitle": "Legacy", + "multisite": false, + "wordpress": { "mode": "image", "tag": "trunk" }, + "muPlugins": { "mode": "image" }, + "mediaRedirectDomain": "", + "phpmyadmin": false, + "xdebug": false, + "php": "php:8.2", + "mailpit": false, + "photon": false, + "cron": false, + "enterpriseSearchEnabled": true, + "clientCode": { "mode": "local", "dir": "/srv/legacy" } +} diff --git a/internal/devenv/instancedata/testdata/unknown_keys.json b/internal/devenv/instancedata/testdata/unknown_keys.json new file mode 100644 index 000000000..7bea9bc64 --- /dev/null +++ b/internal/devenv/instancedata/testdata/unknown_keys.json @@ -0,0 +1,19 @@ +{ + "siteSlug": "example", + "wpTitle": "Example Dev", + "multisite": false, + "wordpress": { "mode": "image", "tag": "trunk" }, + "muPlugins": { "mode": "image" }, + "appCode": { "mode": "local", "dir": "/srv/example" }, + "mediaRedirectDomain": "", + "phpmyadmin": true, + "xdebug": false, + "php": "ghcr.io/automattic/vip-container-images/php-fpm:8.2", + "mailpit": false, + "photon": false, + "cron": false, + "pullAfter": 1700000000000, + "adminPassword": "s3cret", + "futureKeyWeDoNotModel": { "nested": [1, 2, 3] }, + "anotherUnknown": "keep-me" +} diff --git a/internal/devenv/paths/paths.go b/internal/devenv/paths/paths.go new file mode 100644 index 000000000..e794d895a --- /dev/null +++ b/internal/devenv/paths/paths.go @@ -0,0 +1,42 @@ +// Package paths is the single source of truth for vip dev-env on-disk +// locations. Mirrors the Node helpers: xdg-data.ts (xdgData) and +// dev-environment-core.ts (getEnvironmentPath / getAllEnvironmentNames +// base dir). Command logs live per-environment, in a logs/ subdirectory of +// the environment's own instance directory, with one timestamped file per +// invocation (mirroring Node's getDevEnvLogFile). +package paths + +import ( + "os" + "path/filepath" +) + +// XDGData mirrors Node's xdgData(): $XDG_DATA_HOME or ~/.local/share. +func XDGData() string { + if d := os.Getenv("XDG_DATA_HOME"); d != "" { + return d + } + home, err := os.UserHomeDir() + if err != nil { + return "." + } + return filepath.Join(home, ".local", "share") +} + +// DevEnvBase is the directory containing one subdirectory per environment. +// It uses the historical "dev-environment" segment (where existing env data +// already lives). +func DevEnvBase() string { + return filepath.Join(XDGData(), "vip", "dev-environment") +} + +// EnvironmentPath is the directory holding a single environment's state. +func EnvironmentPath(slug string) string { + return filepath.Join(DevEnvBase(), slug) +} + +// EnvLogDir is where an environment's per-invocation command logs live: a +// logs/ subdirectory inside the environment's own instance directory. +func EnvLogDir(slug string) string { + return filepath.Join(EnvironmentPath(slug), "logs") +} diff --git a/internal/devenv/paths/paths_test.go b/internal/devenv/paths/paths_test.go new file mode 100644 index 000000000..31fd890b5 --- /dev/null +++ b/internal/devenv/paths/paths_test.go @@ -0,0 +1,39 @@ +package paths + +import ( + "path/filepath" + "testing" +) + +func TestXDGDataHonorsEnv(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "/tmp/xdgcustom") + if got := XDGData(); got != "/tmp/xdgcustom" { + t.Fatalf("XDGData() = %q, want /tmp/xdgcustom", got) + } +} + +func TestXDGDataFallsBackToHome(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "") + home := t.TempDir() + t.Setenv("HOME", home) + want := filepath.Join(home, ".local", "share") + if got := XDGData(); got != want { + t.Fatalf("XDGData() = %q, want %q", got, want) + } +} + +func TestEnvironmentPath(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "/data") + want := filepath.Join("/data", "vip", "dev-environment", "myslug") + if got := EnvironmentPath("myslug"); got != want { + t.Fatalf("EnvironmentPath = %q, want %q", got, want) + } +} + +func TestEnvLogDir(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "/data") + want := filepath.Join("/data", "vip", "dev-environment", "myslug", "logs") + if got := EnvLogDir("myslug"); got != want { + t.Fatalf("EnvLogDir = %q, want %q", got, want) + } +} From da6f3940eb3f23517d699cad177ba8d5555ed7a9 Mon Sep 17 00:00:00 2001 From: Rinat Khaziev Date: Thu, 20 Aug 2026 18:36:54 -0500 Subject: [PATCH 15/32] feat(devenv): lifecycle, proxy and host operations Ported from vip-cli-golang@72ef2f89 (feature/go-rewrite). --- internal/devenv/hostops/context.go | 38 ++ internal/devenv/hostops/context_test.go | 24 + internal/devenv/hostops/e2e_gate_test.go | 17 + internal/devenv/hostops/e2e_test.go | 429 ++++++++++++++++++ internal/devenv/hostops/elevate.go | 248 ++++++++++ .../devenv/hostops/elevate_message_test.go | 35 ++ internal/devenv/hostops/elevate_test.go | 111 +++++ internal/devenv/hostops/hosts.go | 255 +++++++++++ internal/devenv/hostops/hosts_test.go | 178 ++++++++ internal/devenv/hostops/thumbprint_test.go | 57 +++ internal/devenv/hostops/trust.go | 115 +++++ internal/devenv/hostops/trust_test.go | 90 ++++ internal/devenv/lifecycle/adopt.go | 24 + internal/devenv/lifecycle/adopt_test.go | 43 ++ internal/devenv/lifecycle/health.go | 28 ++ internal/devenv/lifecycle/health_test.go | 34 ++ internal/devenv/lifecycle/hosts.go | 42 ++ internal/devenv/lifecycle/hosts_test.go | 41 ++ internal/devenv/lifecycle/migrate.go | 44 ++ internal/devenv/lifecycle/migrate_test.go | 78 ++++ internal/devenv/lifecycle/pull.go | 19 + internal/devenv/lifecycle/pull_test.go | 29 ++ internal/devenv/lifecycle/setup.go | 35 ++ internal/devenv/lifecycle/setup_test.go | 46 ++ internal/devenv/lifecycle/start.go | 132 ++++++ internal/devenv/lifecycle/start_test.go | 350 ++++++++++++++ internal/devenv/lifecycle/teardown.go | 32 ++ internal/devenv/lifecycle/teardown_test.go | 83 ++++ internal/devenv/lifecycle/types.go | 86 ++++ internal/devenv/lifecycle/waiter.go | 49 ++ internal/devenv/lifecycle/waiter_test.go | 98 ++++ internal/devenv/proxy/ca.go | 103 +++++ internal/devenv/proxy/ca_test.go | 111 +++++ internal/devenv/proxy/network.go | 24 + internal/devenv/proxy/network_test.go | 62 +++ internal/devenv/proxy/ports.go | 104 +++++ internal/devenv/proxy/ports_test.go | 76 ++++ internal/devenv/proxy/proxy.go | 114 +++++ internal/devenv/proxy/proxy_test.go | 94 ++++ internal/devenv/proxy/scripts/gen-certs.sh | 58 +++ internal/devenv/proxy/spec.go | 60 +++ internal/devenv/proxy/spec_test.go | 44 ++ internal/devenv/proxy/testhelpers_test.go | 7 + 43 files changed, 3747 insertions(+) create mode 100644 internal/devenv/hostops/context.go create mode 100644 internal/devenv/hostops/context_test.go create mode 100644 internal/devenv/hostops/e2e_gate_test.go create mode 100644 internal/devenv/hostops/e2e_test.go create mode 100644 internal/devenv/hostops/elevate.go create mode 100644 internal/devenv/hostops/elevate_message_test.go create mode 100644 internal/devenv/hostops/elevate_test.go create mode 100644 internal/devenv/hostops/hosts.go create mode 100644 internal/devenv/hostops/hosts_test.go create mode 100644 internal/devenv/hostops/thumbprint_test.go create mode 100644 internal/devenv/hostops/trust.go create mode 100644 internal/devenv/hostops/trust_test.go create mode 100644 internal/devenv/lifecycle/adopt.go create mode 100644 internal/devenv/lifecycle/adopt_test.go create mode 100644 internal/devenv/lifecycle/health.go create mode 100644 internal/devenv/lifecycle/health_test.go create mode 100644 internal/devenv/lifecycle/hosts.go create mode 100644 internal/devenv/lifecycle/hosts_test.go create mode 100644 internal/devenv/lifecycle/migrate.go create mode 100644 internal/devenv/lifecycle/migrate_test.go create mode 100644 internal/devenv/lifecycle/pull.go create mode 100644 internal/devenv/lifecycle/pull_test.go create mode 100644 internal/devenv/lifecycle/setup.go create mode 100644 internal/devenv/lifecycle/setup_test.go create mode 100644 internal/devenv/lifecycle/start.go create mode 100644 internal/devenv/lifecycle/start_test.go create mode 100644 internal/devenv/lifecycle/teardown.go create mode 100644 internal/devenv/lifecycle/teardown_test.go create mode 100644 internal/devenv/lifecycle/types.go create mode 100644 internal/devenv/lifecycle/waiter.go create mode 100644 internal/devenv/lifecycle/waiter_test.go create mode 100644 internal/devenv/proxy/ca.go create mode 100644 internal/devenv/proxy/ca_test.go create mode 100644 internal/devenv/proxy/network.go create mode 100644 internal/devenv/proxy/network_test.go create mode 100644 internal/devenv/proxy/ports.go create mode 100644 internal/devenv/proxy/ports_test.go create mode 100644 internal/devenv/proxy/proxy.go create mode 100644 internal/devenv/proxy/proxy_test.go create mode 100644 internal/devenv/proxy/scripts/gen-certs.sh create mode 100644 internal/devenv/proxy/spec.go create mode 100644 internal/devenv/proxy/spec_test.go create mode 100644 internal/devenv/proxy/testhelpers_test.go diff --git a/internal/devenv/hostops/context.go b/internal/devenv/hostops/context.go new file mode 100644 index 000000000..a3f509190 --- /dev/null +++ b/internal/devenv/hostops/context.go @@ -0,0 +1,38 @@ +package hostops + +import ( + "os" + "runtime" + "strings" +) + +// ctxKind is the elevation/hosts strategy for the current runtime. +type ctxKind int + +const ( + // ctxUnix: macOS / native Linux — edit /etc/hosts via `sudo /bin/sh`. + ctxUnix ctxKind = iota + // ctxWindows: native Windows OR Linux-inside-WSL — edit the WINDOWS hosts + // file + Windows cert store via `powershell.exe Start-Process -Verb RunAs`. + // WSL targets Windows because the user's browser (on Windows) reads the + // Windows hosts file; WSL's /etc/hosts is regenerated from it. + ctxWindows +) + +// resolveContext maps (GOOS, /proc/version contents, WSL_DISTRO_NAME) to a ctxKind. +// procVersion/wslDistro are injected for testability. +func resolveContext(goos, procVersion, wslDistro string) ctxKind { + if goos == "windows" { + return ctxWindows + } + if goos == "linux" && (wslDistro != "" || strings.Contains(strings.ToLower(procVersion), "microsoft")) { + return ctxWindows + } + return ctxUnix +} + +// currentContext resolves the live runtime context. +func currentContext() ctxKind { + pv, _ := os.ReadFile("/proc/version") + return resolveContext(runtime.GOOS, string(pv), os.Getenv("WSL_DISTRO_NAME")) +} diff --git a/internal/devenv/hostops/context_test.go b/internal/devenv/hostops/context_test.go new file mode 100644 index 000000000..fb784726d --- /dev/null +++ b/internal/devenv/hostops/context_test.go @@ -0,0 +1,24 @@ +package hostops + +import "testing" + +func TestResolveContext(t *testing.T) { + cases := []struct { + goos, procVersion string + want ctxKind + }{ + {"darwin", "", ctxUnix}, + {"linux", "Linux version 6.1.0-generic", ctxUnix}, + {"linux", "Linux version 5.15.90.1-microsoft-standard-WSL2", ctxWindows}, + {"windows", "", ctxWindows}, + } + for _, c := range cases { + if got := resolveContext(c.goos, c.procVersion, ""); got != c.want { + t.Fatalf("resolveContext(%q,%q)=%v want %v", c.goos, c.procVersion, got, c.want) + } + } + // WSL_DISTRO_NAME env is an alternate WSL marker. + if got := resolveContext("linux", "", "Ubuntu"); got != ctxWindows { + t.Fatalf("WSL via env not detected: %v", got) + } +} diff --git a/internal/devenv/hostops/e2e_gate_test.go b/internal/devenv/hostops/e2e_gate_test.go new file mode 100644 index 000000000..d285b840a --- /dev/null +++ b/internal/devenv/hostops/e2e_gate_test.go @@ -0,0 +1,17 @@ +//go:build devenv_e2e + +package hostops + +import ( + "os" + "testing" + + "github.com/Automattic/vip/internal/devenv/e2esafety" +) + +func TestMain(m *testing.M) { + if e2esafety.Skip(os.Getenv, os.Stdout) { + os.Exit(0) + } + os.Exit(m.Run()) +} diff --git a/internal/devenv/hostops/e2e_test.go b/internal/devenv/hostops/e2e_test.go new file mode 100644 index 000000000..eb141d335 --- /dev/null +++ b/internal/devenv/hostops/e2e_test.go @@ -0,0 +1,429 @@ +//go:build devenv_e2e + +// Package hostops e2e harness — the Plan 3 manual integration gate (Task 11). +// +// This is NOT a normal unit test: it drives the REAL proxy + hostops Go code +// against a live Docker daemon and performs the two host-privileged operations +// (trusting the local CA in the System keychain + editing /etc/hosts) under a +// single macOS admin prompt. Because there is no `vip dev-env` command wired to +// these packages yet (Plans 4/5), this harness is the only way to exercise the +// real code paths end-to-end before that wiring lands. +// +// It is gated behind the `devenv_e2e` build tag so it never runs in CI or a +// normal `go test ./...`. Run it explicitly on a macOS machine with Docker: +// +// go test -tags devenv_e2e -run TestProxyHostopsE2E -v \ +// -timeout 5m ./internal/devenv/hostops/ +// +// You will be asked for your admin password ONCE (setup: trust CA + add the +// /etc/hosts entry) and ONCE more at teardown (untrust + remove the entry). +// +// What it proves: +// - proxy.EnsureNetwork / Ensure (real Docker bind + fallback ports) +// - proxy.EnsureCA / EnsureCert (embedded gen-certs.sh in a one-shot) +// - proxy.ExtractCA (docker cp the CA PEM to the host) +// - hostops.Apply: ONE elevation does both trust + /etc/hosts (production path) +// - a plain `curl https://example.vipdev.lndo.site[:port]/` succeeds with +// ssl_verify_result=0 — i.e. SYSTEM trust + /etc/hosts both work, no +// --cacert/--resolve crutches. +// +// Everything it creates is named with the production proxy names and removed in +// teardown (the proxy container, the throwaway nginx backend, the shared +// network, and the certs/proxy_config volumes). +package hostops + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/Automattic/vip/internal/devenv/compose" + "github.com/Automattic/vip/internal/devenv/dockercli" + "github.com/Automattic/vip/internal/devenv/e2esafety" + "github.com/Automattic/vip/internal/devenv/proxy" +) + +const ( + e2eDomain = compose.DefaultDomain // vipdev.lndo.site + e2eHost = "example." + e2eDomain // example.vipdev.lndo.site + e2eWeb = "vip-dev-env-e2e-web" // throwaway backend container + e2eCertCN = e2eHost + e2eBasename = "example" + + resourceBackendContainer = "backend-container" + resourceProxyContainer = "proxy-container" + resourceProxyNetwork = "proxy-network" + resourceCertsVolume = "certs-volume" + resourceConfigVolume = "config-volume" + resourceTrustedCA = "trusted-ca" + resourceManagedHosts = "managed-hosts" + resourceCAHostFile = "ca-host-file" + resourcePortsState = "ports-state-file" +) + +func TestProxyHostopsE2E(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skipf("e2e trust path is macOS-only; GOOS=%s", runtime.GOOS) + } + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not found in PATH") + } + + ctx := context.Background() + r := &dockercli.Runner{} // tees child output to os.Stdout/os.Stderr (Log nil) + + before := captureE2ESnapshot(t, ctx, r) + if err := before.RequireClean(); err != nil { + t.Fatal(err) + } + owned := e2esafety.Snapshot{} + t.Cleanup(func() { teardownOwned(t, ctx, r, owned) }) + + // 1. Shared network + proxy container (real Docker bind + fallback ports). + if err := proxy.EnsureNetwork(ctx, r); err != nil { + t.Fatalf("EnsureNetwork: %v", err) + } + recordOwned(t, owned, captureE2ESnapshot(t, ctx, r), resourceProxyNetwork) + ports, err := proxy.Ensure(ctx, r, proxy.EnsureOptions{Domain: e2eDomain}) + if err != nil { + t.Fatalf("proxy.Ensure: %v", err) + } + recordOwned(t, owned, captureE2ESnapshot(t, ctx, r), + resourceProxyContainer, resourceCertsVolume, + resourceConfigVolume, resourcePortsState) + t.Logf("proxy bound: http=%d https=%d (note: ListenProbe cannot bind <1024 as "+ + "non-root, so 80/443 are pre-skipped to fallbacks unless run as root)", ports.HTTP, ports.HTTPS) + if ports.HTTPS == 0 { + t.Fatalf("no https port chosen: %+v", ports) + } + + // 2. CA + per-env leaf cert (SANs incl. the wildcard, mirroring CertSANs). + if err := proxy.EnsureCA(ctx, r); err != nil { + t.Fatalf("EnsureCA: %v", err) + } + if err := proxy.EnsureCert(ctx, r, proxy.CertRequest{ + Basename: e2eBasename, + CommonName: e2eCertCN, + SANs: []string{e2eHost, "*." + e2eDomain, "localhost"}, + }); err != nil { + t.Fatalf("EnsureCert: %v", err) + } + + // 3. Extract the CA PEM to the host (what hostops.Apply will trust). + caPath, err := proxy.ExtractCA(ctx, r, proxy.CAHostPath()) + if err != nil { + t.Fatalf("ExtractCA: %v", err) + } + recordOwned(t, owned, captureE2ESnapshot(t, ctx, r), resourceCAHostFile) + t.Logf("extracted CA -> %s", caPath) + + // 4. Throwaway nginx backend with the Plan-2 secured-router labels. + if err := r.Docker(ctx, append([]string{ + "run", "-d", "--name", e2eWeb, "--network", compose.ProxyNetwork, + }, webLabels()...)...); err != nil { + t.Fatalf("start backend: %v", err) + } + recordOwned(t, owned, captureE2ESnapshot(t, ctx, r), resourceBackendContainer) + + // 5. THE PRODUCTION ONE-ELEVATION PATH: trust CA + add /etc/hosts in one prompt. + t.Log(">>> macOS will now prompt for your admin password ONCE (trust CA + /etc/hosts) <<<") + if err := Apply(PrivilegedPlan{ + GOOS: runtime.GOOS, + CAPath: caPath, + HostsAdd: []string{e2eHost}, + }); err != nil { + t.Fatalf("hostops.Apply (one-elevation trust+hosts): %v", err) + } + recordOwned(t, owned, captureE2ESnapshot(t, ctx, r), resourceTrustedCA, resourceManagedHosts) + + // 6. Verify the privileged state landed. + assertKeychainHasCA(t) + assertEtcHostsHas(t, e2eHost) + + // 7. Plain HTTPS through the system trust store + /etc/hosts (no crutches). + url := "https://" + e2eHost + if ports.HTTPS != 443 { + url += ":" + strconv.Itoa(ports.HTTPS) + } + assertHTTPSTrusted(t, url) +} + +// webLabels returns the secured (https/tls) Traefik router labels for the +// throwaway nginx backend (port 80), matching compose/labels.go's scheme for +// id "nginx-example". +func webLabels() []string { + const id = "nginx-example" + rule := "HostRegexp(`" + e2eHost + "`)" + kv := map[string]string{ + "traefik.enable": "true", + "traefik.http.routers." + id + "-secured.entrypoints": "https", + "traefik.http.routers." + id + "-secured.rule": rule, + "traefik.http.routers." + id + "-secured.tls": "true", + "traefik.http.routers." + id + "-secured.service": id + "-secured-service", + "traefik.http.services." + id + "-secured-service.loadbalancer.server.port": "80", + } + var out []string + for k, v := range kv { + out = append(out, "--label", k+"="+v) + } + out = append(out, "nginx:alpine") + return out +} + +// assertHTTPSTrusted polls curl (system trust, no --cacert/--resolve) until the +// TLS handshake verifies the cert against the trusted CA. The key signal is +// ssl_verify_result=0; the HTTP status only needs to be non-000 (a route +// reached a backend), since traefik may take a moment to register the router. +func assertHTTPSTrusted(t *testing.T, url string) { + t.Helper() + deadline := time.Now().Add(20 * time.Second) + var last string + for time.Now().Before(deadline) { + out, _ := exec.Command("curl", "--noproxy", "*", "-sS", "-o", "/dev/null", + "-w", "%{http_code} %{ssl_verify_result}", url).CombinedOutput() + last = strings.TrimSpace(string(out)) + fields := strings.Fields(last) + if len(fields) == 2 && fields[1] == "0" && fields[0] != "000" { + t.Logf("HTTPS OK (system-trusted): %s -> http=%s ssl_verify=0", url, fields[0]) + return + } + time.Sleep(2 * time.Second) + } + t.Fatalf("HTTPS via system trust did not verify within timeout: %s (last: %q)", url, last) +} + +func assertKeychainHasCA(t *testing.T) { + t.Helper() + out, err := exec.Command("security", "find-certificate", "-c", "WPVIP Local CA", + "/Library/Keychains/System.keychain").CombinedOutput() + if err != nil { + t.Fatalf("CA not found in System keychain after Apply: %v\n%s", err, out) + } + t.Log("CA present in System keychain ✓") +} + +func assertEtcHostsHas(t *testing.T, host string) { + t.Helper() + out, err := exec.Command("grep", "-F", host, etcHosts).CombinedOutput() + if err != nil || !strings.Contains(string(out), host) { + t.Fatalf("/etc/hosts missing %q after Apply: %v\n%s", host, err, out) + } + t.Logf("/etc/hosts has %s -> 127.0.0.1 ✓", host) +} + +func captureE2ESnapshot(t *testing.T, ctx context.Context, r *dockercli.Runner) e2esafety.Snapshot { + t.Helper() + return e2esafety.Snapshot{ + resourceBackendContainer: dockerObjectIdentity(t, ctx, r, "container", e2eWeb, "{{.Id}}"), + resourceProxyContainer: dockerObjectIdentity(t, ctx, r, "container", proxy.ProxyContainerName, "{{.Id}}"), + resourceProxyNetwork: dockerObjectIdentity(t, ctx, r, "network", compose.ProxyNetwork, "{{.Id}}"), + resourceCertsVolume: dockerObjectIdentity(t, ctx, r, "volume", proxy.ProxyCertsVolume, "{{.Name}}|{{.CreatedAt}}"), + resourceConfigVolume: dockerObjectIdentity(t, ctx, r, "volume", proxy.ProxyConfigVolume, "{{.Name}}|{{.CreatedAt}}"), + resourceTrustedCA: trustedCAIdentity(t), + resourceManagedHosts: managedHostsIdentity(t, etcHosts), + resourceCAHostFile: fileIdentity(t, proxy.CAHostPath()), + resourcePortsState: fileIdentity(t, proxy.PortsStatePath()), + } +} + +func dockerObjectIdentity(t *testing.T, ctx context.Context, r *dockercli.Runner, kind, name, format string) string { + t.Helper() + out, err := r.DockerOut(ctx, kind, "inspect", "--format", format, name) + if err == nil { + identity := strings.TrimSpace(string(out)) + if identity == "" { + t.Fatalf("docker %s inspect returned an empty identity for %q", kind, name) + } + return identity + } + + listFormat := "{{.Name}}" + var listed []byte + var listErr error + if kind == "container" { + listFormat = "{{.Names}}" + listed, listErr = r.DockerOut(ctx, kind, "ls", "--all", "--filter", "name="+name, "--format", listFormat) + } else { + listed, listErr = r.DockerOut(ctx, kind, "ls", "--filter", "name="+name, "--format", listFormat) + } + if listErr != nil { + t.Fatalf("docker %s lookup for %q failed after inspect error: %v", kind, name, listErr) + } + for _, candidate := range strings.Split(strings.TrimSpace(string(listed)), "\n") { + if candidate == name { + t.Fatalf("docker %s %q exists but its identity could not be inspected: %v", kind, name, err) + } + } + return "" +} + +func trustedCAIdentity(t *testing.T) string { + t.Helper() + out, err := exec.Command("security", "find-certificate", "-a", "-c", "WPVIP Local CA", "-p", + "/Library/Keychains/System.keychain").CombinedOutput() + if err != nil { + if strings.Contains(string(out), "could not be found") { + return "" + } + t.Fatalf("read trusted WPVIP Local CA: %v: %s", err, strings.TrimSpace(string(out))) + } + block, rest := pem.Decode(out) + if block == nil { + t.Fatal("trusted WPVIP Local CA is not valid PEM") + } + if len(strings.TrimSpace(string(rest))) != 0 { + t.Fatal("multiple WPVIP Local CA certificates found; refusing ambiguous ownership") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("parse trusted WPVIP Local CA: %v", err) + } + return hashIdentity(cert.Raw) +} + +func managedHostsIdentity(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read hosts file %s: %v", path, err) + } + content := string(b) + if strings.Count(content, beginMarker) != strings.Count(content, endMarker) { + t.Fatalf("malformed managed hosts block in %s", path) + } + if strings.Count(content, beginMarker) > 1 { + t.Fatalf("multiple managed hosts blocks found in %s; refusing ambiguous ownership", path) + } + start := strings.Index(content, beginMarker) + end := strings.Index(content, endMarker) + if start < 0 && end < 0 { + return "" + } + if start < 0 || end < start { + t.Fatalf("malformed managed hosts block in %s", path) + } + end += len(endMarker) + if end < len(content) && content[end] == '\n' { + end++ + } + return hashIdentity([]byte(content[start:end])) +} + +func fileIdentity(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "" + } + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return hashIdentity(b) +} + +func hashIdentity(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +func recordOwned(t *testing.T, owned, current e2esafety.Snapshot, names ...string) { + t.Helper() + for _, name := range names { + if current[name] == "" { + t.Fatalf("created resource %s has no identity", name) + } + owned[name] = current[name] + } +} + +func teardownOwned(t *testing.T, ctx context.Context, r *dockercli.Runner, owned e2esafety.Snapshot) { + t.Helper() + current := captureE2ESnapshot(t, ctx, r) + + removeDocker := func(key string, args ...string) { + if !e2esafety.CanRemove(owned[key], current[key]) { + if owned[key] != "" { + t.Logf("%s identity changed; refusing removal (manual cleanup may be required)", key) + } + return + } + if err := r.Docker(ctx, args...); err != nil { + t.Logf("remove owned %s: %v", key, err) + } + } + removeDocker(resourceBackendContainer, "rm", "-f", e2eWeb) + removeDocker(resourceProxyContainer, "rm", "-f", proxy.ProxyContainerName) + removeDocker(resourceConfigVolume, "volume", "rm", proxy.ProxyConfigVolume) + removeDocker(resourceCertsVolume, "volume", "rm", proxy.ProxyCertsVolume) + removeDocker(resourceProxyNetwork, "network", "rm", compose.ProxyNetwork) + + var scriptLines []string + if e2esafety.CanRemove(owned[resourceTrustedCA], current[resourceTrustedCA]) && + e2esafety.CanRemove(owned[resourceCAHostFile], current[resourceCAHostFile]) { + if argv, err := untrustCommand(runtime.GOOS, proxy.CAHostPath()); err == nil { + scriptLines = append(scriptLines, shellJoin(argv)) + } else { + t.Logf("owned trusted CA cannot be removed automatically: %v", err) + } + } else if owned[resourceTrustedCA] != "" { + t.Log("trusted CA or extracted CA identity changed; refusing untrust (manual cleanup may be required)") + } + if e2esafety.CanRemove(owned[resourceManagedHosts], current[resourceManagedHosts]) { + scriptLines = append(scriptLines, stripBlockScript()) + } else if owned[resourceManagedHosts] != "" { + t.Log("managed hosts identity changed; refusing removal (manual cleanup may be required)") + } + if len(scriptLines) > 0 { + t.Log(">>> sudo will prompt once to remove only identity-matched privileged state <<<") + if err := runElevatedScript("#!/bin/sh\nset -e\n" + strings.Join(scriptLines, "\n") + "\n"); err != nil { + t.Logf("owned privileged teardown failed: %v", err) + } + } + + removeOwnedFile := func(key, path string) { + if !e2esafety.CanRemove(owned[key], current[key]) { + if owned[key] != "" { + t.Logf("%s identity changed; refusing file removal (manual cleanup may be required)", key) + } + return + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + t.Logf("remove owned %s: %v", key, err) + } + } + removeOwnedFile(resourceCAHostFile, proxy.CAHostPath()) + removeOwnedFile(resourcePortsState, proxy.PortsStatePath()) +} + +// runElevatedScript runs a /bin/sh script once under a single sudo prompt. +// Test-only mirror of Apply's exec, used for teardown (which must untrust — +// something Apply/PrivilegedPlan does not model). +func runElevatedScript(script string) error { + f, err := os.CreateTemp("", "vip-dev-env-e2e-teardown-*.sh") + if err != nil { + return err + } + name := f.Name() + defer os.Remove(name) + if _, err := f.WriteString(script); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + cmd := exec.Command("sudo", "/bin/sh", name) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/internal/devenv/hostops/elevate.go b/internal/devenv/hostops/elevate.go new file mode 100644 index 000000000..6b2856328 --- /dev/null +++ b/internal/devenv/hostops/elevate.go @@ -0,0 +1,248 @@ +package hostops + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// etcHosts is the real hosts file the elevated script rewrites (as root). The +// unit-tested EnsureHosts/RemoveHosts in hosts.go are path-injected; the +// privileged path uses this fixed location. +const etcHosts = "/etc/hosts" + +// PrivilegedPlan describes the host-privileged operations to run under a single +// elevation: trusting the CA (CAPath) and/or rewriting the managed /etc/hosts +// block (HostsAdd) or removing it (HostsRemove). +type PrivilegedPlan struct { + GOOS string + CAPath string + // HostsAdd is the list of hostnames to write into the managed /etc/hosts block. + // WARNING: wildcard hostnames (e.g. *.example.test) are valid TLS SANs but are + // NOT valid /etc/hosts entries — the resolver ignores them. Callers (Plan 4) + // must filter wildcard SANs out of CertSANs before passing them here. + HostsAdd []string + HostsRemove bool +} + +// shellQuote single-quotes s for safe POSIX-sh interpolation. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// shellJoin shell-quotes each argv element and joins them with spaces. +func shellJoin(argv []string) string { + q := make([]string, len(argv)) + for i, a := range argv { + q[i] = shellQuote(a) + } + return strings.Join(q, " ") +} + +// stripBlockScript emits sh that removes the managed block from /etc/hosts via a +// temp file, overwriting through a redirect (not mv) so the file keeps its +// existing ownership/permissions. +func stripBlockScript() string { + return fmt.Sprintf(`__vip_tmp="$(mktemp)" +sed -e '/^%s$/,/^%s$/d' '%s' > "$__vip_tmp" +cat "$__vip_tmp" > '%s' +rm -f "$__vip_tmp" +`, beginMarker, endMarker, etcHosts, etcHosts) +} + +// buildPrivilegedScript returns a single /bin/sh program performing all of the +// plan's privileged operations, to be run once under elevation. Trust runs +// first; the /etc/hosts rewrite happens in-script (as root). Returns an error +// if the OS is unsupported for trust or a hostname is invalid. +func buildPrivilegedScript(plan PrivilegedPlan) (string, error) { + var b strings.Builder + b.WriteString("#!/bin/sh\nset -e\n") + if plan.CAPath != "" { + argv, err := trustCommand(plan.GOOS, plan.CAPath) + if err != nil { + return "", err + } + b.WriteString(shellJoin(argv) + "\n") + } + // HostsAdd takes precedence over HostsRemove when both are set. + switch { + case len(plan.HostsAdd) > 0: + if err := validateHosts(plan.HostsAdd); err != nil { + return "", err + } + b.WriteString(stripBlockScript()) + b.WriteString(fmt.Sprintf("cat >> %s <<'__VIP_HOSTS_EOF__'\n", etcHosts)) + b.WriteString(renderBlock(plan.HostsAdd)) + b.WriteString("__VIP_HOSTS_EOF__\n") + case plan.HostsRemove: + b.WriteString(stripBlockScript()) + } + return b.String(), nil +} + +// windowsHostsPath is the hosts file PowerShell edits (from native Windows or +// WSL via powershell.exe). $env:SystemRoot expands at runtime. +const windowsHostsPath = `$env:SystemRoot\System32\drivers\etc\hosts` + +// buildWindowsScript returns a PowerShell program that (optionally) trusts the +// CA via certutil and rewrites the managed block in the Windows hosts file. +// Mirrors buildPrivilegedScript but for the Windows target (native + WSL). +func buildWindowsScript(plan PrivilegedPlan) (string, error) { + var b strings.Builder + b.WriteString("$ErrorActionPreference = 'Stop'\n") + if plan.CAPath != "" { + b.WriteString(fmt.Sprintf("certutil -addstore -f Root %s\n", psQuote(plan.CAPath))) + } + switch { + case len(plan.HostsAdd) > 0: + if err := validateHosts(plan.HostsAdd); err != nil { + return "", err + } + b.WriteString(fmt.Sprintf("$hf = \"%s\"\n", windowsHostsPath)) + b.WriteString("$lines = if (Test-Path $hf) { Get-Content $hf } else { @() }\n") + b.WriteString(fmt.Sprintf("$out = @(); $in = $false\nforeach ($l in $lines) { if ($l.Trim() -eq %s) { $in = $true; continue }; if ($l.Trim() -eq %s) { $in = $false; continue }; if (-not $in) { $out += $l } }\n", psQuote(beginMarker), psQuote(endMarker))) + b.WriteString(fmt.Sprintf("$out += %s\n", psQuote(beginMarker))) + for _, h := range plan.HostsAdd { + b.WriteString(fmt.Sprintf("$out += %s\n", psQuote("127.0.0.1 "+h))) + } + b.WriteString(fmt.Sprintf("$out += %s\n", psQuote(endMarker))) + b.WriteString("Set-Content -Path $hf -Value $out -Encoding ASCII\n") + case plan.HostsRemove: + b.WriteString(fmt.Sprintf("$hf = \"%s\"\n", windowsHostsPath)) + b.WriteString("if (Test-Path $hf) { $lines = Get-Content $hf; $out = @(); $in = $false\n") + b.WriteString(fmt.Sprintf("foreach ($l in $lines) { if ($l.Trim() -eq %s) { $in = $true; continue }; if ($l.Trim() -eq %s) { $in = $false; continue }; if (-not $in) { $out += $l } }\n", psQuote(beginMarker), psQuote(endMarker))) + b.WriteString("Set-Content -Path $hf -Value $out -Encoding ASCII }\n") + } + return b.String(), nil +} + +// psQuote single-quotes s for PowerShell (doubling embedded single quotes). +func psQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + +// planActions describes, in plain language, what a privileged plan will change — +// used to tell the user why a UAC / sudo prompt is about to appear. +func planActions(plan PrivilegedPlan) []string { + var a []string + if plan.CAPath != "" { + a = append(a, "trust the local development HTTPS certificate") + } + switch { + case len(plan.HostsAdd) > 0: + a = append(a, "add local hostnames to your hosts file") + case plan.HostsRemove: + a = append(a, "remove local hostnames from your hosts file") + } + return a +} + +// joinAnd joins phrases into "a", "a and b", or "a, b, and c". +func joinAnd(items []string) string { + switch len(items) { + case 0: + return "" + case 1: + return items[0] + case 2: + return items[0] + " and " + items[1] + default: + return strings.Join(items[:len(items)-1], ", ") + ", and " + items[len(items)-1] + } +} + +// Apply runs the plan's privileged operations under a SINGLE elevation, +// dispatching by runtime context: ctxWindows (native Windows / WSL) edits the +// Windows hosts file + cert store via powershell.exe RunAs; everything else +// (macOS / native Linux) edits /etc/hosts via sudo /bin/sh. +func Apply(plan PrivilegedPlan) error { + if currentContext() == ctxWindows { + return applyWindows(plan) + } + return applyUnix(plan) +} + +// applyWindows writes the PowerShell script to a temp .ps1 and runs it elevated. +// From native Windows AND from WSL, `powershell.exe` is invokable; Start-Process +// -Verb RunAs triggers the UAC prompt and edits the Windows hosts/cert store. +func applyWindows(plan PrivilegedPlan) error { + script, err := buildWindowsScript(plan) + if err != nil { + return err + } + f, err := os.CreateTemp("", "vip-dev-env-priv-*.ps1") + if err != nil { + return err + } + name := f.Name() + defer os.Remove(name) + if _, err := f.WriteString(script); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + if actions := planActions(plan); len(actions) > 0 { + fmt.Fprintf(os.Stderr, "\nAdministrator access is needed to %s.\nApprove the Windows (UAC) prompt to continue...\n", joinAnd(actions)) + } + inner := fmt.Sprintf("$p = Start-Process powershell -Verb RunAs -Wait -PassThru -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','%s'; exit $p.ExitCode", name) + cmd := exec.Command("powershell.exe", "-NoProfile", "-Command", inner) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return err + } + if plan.CAPath != "" { + fmt.Fprintln(os.Stderr, "Local HTTPS certificate trusted. Restart your browser for it to take effect.") + } + return nil +} + +// applyUnix runs the plan's privileged operations under a SINGLE elevation — one +// `sudo /bin/sh