From 78b2ff730b080f8849ac7714e5fa0ea5ec13f6d1 Mon Sep 17 00:00:00 2001 From: aantoni Date: Wed, 21 Jan 2026 13:50:18 +0100 Subject: [PATCH 01/28] experimenting with k8s client --- cmd/osmanage/main.go | 17 + go.mod | 40 +- go.sum | 121 +++++- internal/k8s/actions/cluster_status.go | 165 ++++++++ internal/k8s/actions/cluster_status_test.go | 360 ++++++++++++++++++ internal/k8s/actions/create.go | 141 +++++++ internal/k8s/actions/create_test.go | 375 +++++++++++++++++++ internal/k8s/actions/health.go | 163 ++++++++ internal/k8s/actions/health_test.go | 62 +++ internal/k8s/actions/remove.go | 86 +++++ internal/k8s/actions/remove_test.go | 245 ++++++++++++ internal/k8s/actions/start.go | 212 +++++++++++ internal/k8s/actions/start_test.go | 294 +++++++++++++++ internal/k8s/actions/stop.go | 147 ++++++++ internal/k8s/actions/stop_test.go | 308 +++++++++++++++ internal/k8s/actions/update_backendmanage.go | 71 ++++ internal/k8s/client/client.go | 53 +++ 17 files changed, 2847 insertions(+), 13 deletions(-) create mode 100644 internal/k8s/actions/cluster_status.go create mode 100644 internal/k8s/actions/cluster_status_test.go create mode 100644 internal/k8s/actions/create.go create mode 100644 internal/k8s/actions/create_test.go create mode 100644 internal/k8s/actions/health.go create mode 100644 internal/k8s/actions/health_test.go create mode 100644 internal/k8s/actions/remove.go create mode 100644 internal/k8s/actions/remove_test.go create mode 100644 internal/k8s/actions/start.go create mode 100644 internal/k8s/actions/start_test.go create mode 100644 internal/k8s/actions/stop.go create mode 100644 internal/k8s/actions/stop_test.go create mode 100644 internal/k8s/actions/update_backendmanage.go create mode 100644 internal/k8s/client/client.go diff --git a/cmd/osmanage/main.go b/cmd/osmanage/main.go index 61978a7..b4cb6a1 100644 --- a/cmd/osmanage/main.go +++ b/cmd/osmanage/main.go @@ -11,6 +11,7 @@ import ( "github.com/OpenSlides/openslides-cli/internal/actions/migrations" "github.com/OpenSlides/openslides-cli/internal/actions/set" "github.com/OpenSlides/openslides-cli/internal/actions/setpassword" + k8sActions "github.com/OpenSlides/openslides-cli/internal/k8s/actions" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/OpenSlides/openslides-cli/internal/templating/config" "github.com/OpenSlides/openslides-cli/internal/templating/setup" @@ -62,6 +63,21 @@ func RootCmd() *cobra.Command { return nil } + // K8s command group + k8sCmd := &cobra.Command{ + Use: "k8s", + Short: "Manage Kubernetes deployments", + Long: "Manage OpenSlides instances deployed on Kubernetes", + } + + k8sCmd.AddCommand( + k8sActions.StartCmd(), + k8sActions.StopCmd(), + k8sActions.CreateCmd(), + k8sActions.HealthCmd(), + k8sActions.ClusterStatusCmd(), + ) + rootCmd.AddCommand( setup.Cmd(), config.Cmd(), @@ -72,6 +88,7 @@ func RootCmd() *cobra.Command { get.Cmd(), action.Cmd(), migrations.Cmd(), + k8sCmd, ) return rootCmd diff --git a/go.mod b/go.mod index 49e5960..089f6b4 100644 --- a/go.mod +++ b/go.mod @@ -7,17 +7,53 @@ require ( github.com/shopspring/decimal v1.4.0 github.com/spf13/cobra v1.10.2 golang.org/x/text v0.33.0 + k8s.io/api v0.35.0 + k8s.io/apimachinery v0.35.0 + k8s.io/client-go v0.35.0 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.6 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/crypto v0.42.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.44.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.37.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/time v0.9.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) require ( diff --git a/go.sum b/go.sum index 72a9f9f..02f8dc7 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= @@ -13,6 +15,7 @@ github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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= @@ -24,16 +27,41 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -44,16 +72,37 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -66,8 +115,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= @@ -78,30 +127,80 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT 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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 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= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/k8s/actions/cluster_status.go b/internal/k8s/actions/cluster_status.go new file mode 100644 index 0000000..2b1693a --- /dev/null +++ b/internal/k8s/actions/cluster_status.go @@ -0,0 +1,165 @@ +package actions + +import ( + "context" + "fmt" + + "github.com/OpenSlides/openslides-cli/internal/k8s/client" + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/spf13/cobra" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + ClusterStatusHelp = "Check Kubernetes cluster status" + ClusterStatusHelpExtra = `Checks the status of the Kubernetes cluster by querying node conditions. +Reports the total number of nodes and how many are in Ready state. + +Examples: + osmanage k8s cluster-status + osmanage k8s cluster-status --kubeconfig ~/.kube/config` +) + +type NodeStatus struct { + Name string + Ready bool + Conditions []corev1.NodeCondition +} + +type ClusterStatus struct { + TotalNodes int + ReadyNodes int + Nodes []NodeStatus +} + +func ClusterStatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cluster-status", + Short: ClusterStatusHelp, + Long: ClusterStatusHelp + "\n\n" + ClusterStatusHelpExtra, + Args: cobra.NoArgs, + } + + kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + logger.Info("=== K8S CLUSTER STATUS ===") + + k8sClient, err := client.New(*kubeconfig) + if err != nil { + return fmt.Errorf("creating k8s client: %w", err) + } + + ctx := context.Background() + + status, err := checkClusterStatus(ctx, k8sClient) + if err != nil { + return fmt.Errorf("checking cluster status: %w", err) + } + + logger.Info("Total nodes: %d", status.TotalNodes) + logger.Info("Ready nodes: %d", status.ReadyNodes) + + for _, node := range status.Nodes { + statusStr := "NotReady" + if node.Ready { + statusStr = "Ready" + } + logger.Info("Node %s: %s", node.Name, statusStr) + + if !node.Ready { + for _, condition := range node.Conditions { + if condition.Status == corev1.ConditionTrue && condition.Type != corev1.NodeReady { + logger.Debug(" - %s: %s (Reason: %s)", condition.Type, condition.Message, condition.Reason) + } + } + } + } + + if status.ReadyNodes < status.TotalNodes { + return fmt.Errorf("cluster is not healthy: %d/%d nodes ready", status.ReadyNodes, status.TotalNodes) + } + + logger.Info("Cluster is healthy ✓") + return nil + } + + return cmd +} + +// checkClusterStatus retrieves and analyzes the cluster status +func checkClusterStatus(ctx context.Context, k8sClient *client.Client) (*ClusterStatus, error) { + clientset := k8sClient.Clientset() + + nodes, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("listing nodes: %w", err) + } + + status := &ClusterStatus{ + TotalNodes: len(nodes.Items), + Nodes: make([]NodeStatus, 0, len(nodes.Items)), + } + + for _, node := range nodes.Items { + nodeStatus := NodeStatus{ + Name: node.Name, + Ready: isNodeReady(&node), + Conditions: node.Status.Conditions, + } + + if nodeStatus.Ready { + status.ReadyNodes++ + } + + status.Nodes = append(status.Nodes, nodeStatus) + } + + return status, nil +} + +// isNodeReady checks if a node is in Ready state +func isNodeReady(node *corev1.Node) bool { + for _, condition := range node.Status.Conditions { + if condition.Type == corev1.NodeReady { + return condition.Status == corev1.ConditionTrue + } + } + return false +} + +// GetNodeCondition retrieves a specific condition from a node +func GetNodeCondition(node *corev1.Node, conditionType corev1.NodeConditionType) *corev1.NodeCondition { + for i := range node.Status.Conditions { + if node.Status.Conditions[i].Type == conditionType { + return &node.Status.Conditions[i] + } + } + return nil +} + +// IsNodeHealthy checks if a node has any problematic conditions +func IsNodeHealthy(node *corev1.Node) bool { + readyCondition := GetNodeCondition(node, corev1.NodeReady) + if readyCondition == nil || readyCondition.Status != corev1.ConditionTrue { + return false + } + + negativeConditions := []corev1.NodeConditionType{ + corev1.NodeMemoryPressure, + corev1.NodeDiskPressure, + corev1.NodePIDPressure, + corev1.NodeNetworkUnavailable, + } + + for _, condType := range negativeConditions { + condition := GetNodeCondition(node, condType) + if condition != nil && condition.Status == corev1.ConditionTrue { + return false + } + } + + return true +} diff --git a/internal/k8s/actions/cluster_status_test.go b/internal/k8s/actions/cluster_status_test.go new file mode 100644 index 0000000..1374ac5 --- /dev/null +++ b/internal/k8s/actions/cluster_status_test.go @@ -0,0 +1,360 @@ +package actions + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestIsNodeReady_Ready(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + { + Type: corev1.NodeReady, + Status: corev1.ConditionTrue, + }, + }, + }, + } + + if !isNodeReady(node) { + t.Error("Expected node to be ready") + } +} + +func TestIsNodeReady_NotReady(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + { + Type: corev1.NodeReady, + Status: corev1.ConditionFalse, + }, + }, + }, + } + + if isNodeReady(node) { + t.Error("Expected node to not be ready") + } +} + +func TestIsNodeReady_NoCondition(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{}, + }, + } + + if isNodeReady(node) { + t.Error("Expected node to not be ready when no Ready condition exists") + } +} + +func TestGetNodeCondition_Exists(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + { + Type: corev1.NodeReady, + Status: corev1.ConditionTrue, + }, + { + Type: corev1.NodeMemoryPressure, + Status: corev1.ConditionFalse, + }, + }, + }, + } + + condition := GetNodeCondition(node, corev1.NodeReady) + if condition == nil { + t.Fatal("Expected to find Ready condition") + } + + if condition.Type != corev1.NodeReady { + t.Errorf("Expected condition type %v, got %v", corev1.NodeReady, condition.Type) + } + + if condition.Status != corev1.ConditionTrue { + t.Errorf("Expected condition status %v, got %v", corev1.ConditionTrue, condition.Status) + } +} + +func TestGetNodeCondition_NotExists(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + { + Type: corev1.NodeReady, + Status: corev1.ConditionTrue, + }, + }, + }, + } + + condition := GetNodeCondition(node, corev1.NodeDiskPressure) + if condition != nil { + t.Error("Expected condition to be nil when not found") + } +} + +func TestIsNodeHealthy_Healthy(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + { + Type: corev1.NodeReady, + Status: corev1.ConditionTrue, + }, + { + Type: corev1.NodeMemoryPressure, + Status: corev1.ConditionFalse, + }, + { + Type: corev1.NodeDiskPressure, + Status: corev1.ConditionFalse, + }, + { + Type: corev1.NodePIDPressure, + Status: corev1.ConditionFalse, + }, + }, + }, + } + + if !IsNodeHealthy(node) { + t.Error("Expected node to be healthy") + } +} + +func TestIsNodeHealthy_NotReady(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + { + Type: corev1.NodeReady, + Status: corev1.ConditionFalse, + }, + { + Type: corev1.NodeMemoryPressure, + Status: corev1.ConditionFalse, + }, + }, + }, + } + + if IsNodeHealthy(node) { + t.Error("Expected node to not be healthy when not ready") + } +} + +func TestIsNodeHealthy_MemoryPressure(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + { + Type: corev1.NodeReady, + Status: corev1.ConditionTrue, + }, + { + Type: corev1.NodeMemoryPressure, + Status: corev1.ConditionTrue, + }, + }, + }, + } + + if IsNodeHealthy(node) { + t.Error("Expected node to not be healthy when memory pressure is true") + } +} + +func TestIsNodeHealthy_DiskPressure(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + { + Type: corev1.NodeReady, + Status: corev1.ConditionTrue, + }, + { + Type: corev1.NodeDiskPressure, + Status: corev1.ConditionTrue, + }, + }, + }, + } + + if IsNodeHealthy(node) { + t.Error("Expected node to not be healthy when disk pressure is true") + } +} + +func TestCheckClusterStatus_AllNodesReady(t *testing.T) { + nodes := []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "node-2"}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "node-3"}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + }, + }, + }, + } + + fakeClient := fake.NewClientset(&nodes[0], &nodes[1], &nodes[2]) + + ctx := context.Background() + + status, err := checkClusterStatusWithClientset(ctx, fakeClient) + if err != nil { + t.Fatalf("checkClusterStatus failed: %v", err) + } + + if status.TotalNodes != 3 { + t.Errorf("Expected 3 total nodes, got %d", status.TotalNodes) + } + + if status.ReadyNodes != 3 { + t.Errorf("Expected 3 ready nodes, got %d", status.ReadyNodes) + } + + for i, nodeStatus := range status.Nodes { + if !nodeStatus.Ready { + t.Errorf("Expected node %s to be ready", nodes[i].Name) + } + } +} + +func TestCheckClusterStatus_SomeNodesNotReady(t *testing.T) { + nodes := []corev1.Node{ + { + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "node-2"}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionFalse}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "node-3"}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + }, + }, + }, + } + + fakeClient := fake.NewClientset(&nodes[0], &nodes[1], &nodes[2]) + + ctx := context.Background() + + status, err := checkClusterStatusWithClientset(ctx, fakeClient) + if err != nil { + t.Fatalf("checkClusterStatus failed: %v", err) + } + + if status.TotalNodes != 3 { + t.Errorf("Expected 3 total nodes, got %d", status.TotalNodes) + } + + if status.ReadyNodes != 2 { + t.Errorf("Expected 2 ready nodes, got %d", status.ReadyNodes) + } + + expectedReady := map[string]bool{ + "node-1": true, + "node-2": false, + "node-3": true, + } + + for _, nodeStatus := range status.Nodes { + expectedStatus, exists := expectedReady[nodeStatus.Name] + if !exists { + t.Errorf("Unexpected node: %s", nodeStatus.Name) + continue + } + + if nodeStatus.Ready != expectedStatus { + t.Errorf("Node %s: expected ready=%v, got ready=%v", nodeStatus.Name, expectedStatus, nodeStatus.Ready) + } + } +} + +func TestCheckClusterStatus_NoNodes(t *testing.T) { + fakeClient := fake.NewClientset() + + ctx := context.Background() + + status, err := checkClusterStatusWithClientset(ctx, fakeClient) + if err != nil { + t.Fatalf("checkClusterStatus failed: %v", err) + } + + if status.TotalNodes != 0 { + t.Errorf("Expected 0 total nodes, got %d", status.TotalNodes) + } + + if status.ReadyNodes != 0 { + t.Errorf("Expected 0 ready nodes, got %d", status.ReadyNodes) + } +} + +// Helper function to test checkClusterStatus with a fake clientset +func checkClusterStatusWithClientset(ctx context.Context, clientset *fake.Clientset) (*ClusterStatus, error) { + nodes, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + + status := &ClusterStatus{ + TotalNodes: len(nodes.Items), + Nodes: make([]NodeStatus, 0, len(nodes.Items)), + } + + for _, node := range nodes.Items { + nodeStatus := NodeStatus{ + Name: node.Name, + Ready: isNodeReady(&node), + Conditions: node.Status.Conditions, + } + + if nodeStatus.Ready { + status.ReadyNodes++ + } + + status.Nodes = append(status.Nodes, nodeStatus) + } + + return status, nil +} diff --git a/internal/k8s/actions/create.go b/internal/k8s/actions/create.go new file mode 100644 index 0000000..78ce773 --- /dev/null +++ b/internal/k8s/actions/create.go @@ -0,0 +1,141 @@ +package actions + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/spf13/cobra" +) + +const ( + CreateHelp = "Create an OpenSlides instance with custom passwords" + CreateHelpExtra = `Creates an OpenSlides instance by setting up the secrets directory +with the provided database and superadmin passwords. + +This command: +1. Creates/secures the secrets directory (700 permissions) +2. Sets all secret files to 600 permissions +3. Writes the database password to postgres_password +4. Writes the superadmin password to superadmin + +The secrets directory must already exist (created by 'setup' command). + +Examples: + osmanage k8s create --project-dir ./my-instance --db-password "mydbpass" --superadmin-password "myadminpass" + osmanage k8s create -d ./instance --db-password "$(cat db.txt)" --superadmin-password "$(cat admin.txt)"` + + adminSecretsFile = "superadmin" + pgPasswordFile = "postgres_password" +) + +func CreateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: CreateHelp, + Long: CreateHelp + "\n\n" + CreateHelpExtra, + Args: cobra.NoArgs, + } + + projectDir := cmd.Flags().StringP("project-dir", "d", "", "Project directory containing secrets (required)") + dbPassword := cmd.Flags().String("db-password", "", "PostgreSQL database password (required)") + superadminPassword := cmd.Flags().String("superadmin-password", "", "Superadmin password (required)") + + _ = cmd.MarkFlagRequired("project-dir") + _ = cmd.MarkFlagRequired("db-password") + _ = cmd.MarkFlagRequired("superadmin-password") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + logger.Info("=== K8S CREATE INSTANCE ===") + logger.Debug("Project directory: %s", *projectDir) + + if err := createInstance(*projectDir, *dbPassword, *superadminPassword); err != nil { + return fmt.Errorf("creating instance: %w", err) + } + + logger.Info("Instance created successfully") + return nil + } + + return cmd +} + +// createInstance sets up the secrets directory with the provided passwords +func createInstance(projectDir, dbPassword, superadminPassword string) error { + secretsDir := filepath.Join(projectDir, "secrets") + + if _, err := os.Stat(secretsDir); os.IsNotExist(err) { + return fmt.Errorf("secrets directory does not exist: %s (run 'setup' first)", secretsDir) + } + + logger.Info("Creating instance: %s", filepath.Base(projectDir)) + + logger.Debug("Securing secrets directory: %s", secretsDir) + if err := secureSecretsDirectory(secretsDir); err != nil { + return fmt.Errorf("securing secrets directory: %w", err) + } + + pgPasswordPath := filepath.Join(secretsDir, pgPasswordFile) + logger.Debug("Writing PostgreSQL password to: %s", pgPasswordPath) + if err := writeSecretFile(pgPasswordPath, dbPassword); err != nil { + return fmt.Errorf("writing postgres password: %w", err) + } + + superadminPath := filepath.Join(secretsDir, adminSecretsFile) + logger.Debug("Writing superadmin password to: %s", superadminPath) + if err := writeSecretFile(superadminPath, superadminPassword); err != nil { + return fmt.Errorf("writing superadmin password: %w", err) + } + + logger.Info("Passwords configured successfully") + return nil +} + +// secureSecretsDirectory sets restrictive permissions on the secrets directory and all files within +func secureSecretsDirectory(secretsDir string) error { + if err := os.Chmod(secretsDir, 0700); err != nil { + return fmt.Errorf("setting directory permissions: %w", err) + } + + entries, err := os.ReadDir(secretsDir) + if err != nil { + return fmt.Errorf("reading secrets directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + filePath := filepath.Join(secretsDir, entry.Name()) + if err := os.Chmod(filePath, 0600); err != nil { + return fmt.Errorf("setting permissions for %s: %w", entry.Name(), err) + } + } + + return nil +} + +// writeSecretFile writes a secret to a file with secure permissions +func writeSecretFile(path, secret string) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("opening file: %w", err) + } + defer func() { + if closeErr := file.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("closing file %s: %w", file.Name(), closeErr) + } + }() + + if err := file.Chmod(0600); err != nil { + return fmt.Errorf("setting file permissions: %w", err) + } + + if _, err := file.WriteString(secret); err != nil { + return fmt.Errorf("writing secret: %w", err) + } + + return nil +} diff --git a/internal/k8s/actions/create_test.go b/internal/k8s/actions/create_test.go new file mode 100644 index 0000000..62a5ad9 --- /dev/null +++ b/internal/k8s/actions/create_test.go @@ -0,0 +1,375 @@ +package actions + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWriteSecretFile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "create-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + secretPath := filepath.Join(tmpDir, "test_secret") + secretValue := "my-secret-password" + + // Write secret + err = writeSecretFile(secretPath, secretValue) + if err != nil { + t.Fatalf("writeSecretFile failed: %v", err) + } + + // Verify file exists + if _, err := os.Stat(secretPath); os.IsNotExist(err) { + t.Errorf("Secret file was not created at %s", secretPath) + } + + // Verify file permissions are 0600 + fileInfo, err := os.Stat(secretPath) + if err != nil { + t.Fatalf("Failed to stat secret file: %v", err) + } + + expectedPerms := os.FileMode(0600) + if fileInfo.Mode().Perm() != expectedPerms { + t.Errorf("Secret file permissions = %v, want %v", fileInfo.Mode().Perm(), expectedPerms) + } + + // Verify content + content, err := os.ReadFile(secretPath) + if err != nil { + t.Fatalf("Failed to read secret file: %v", err) + } + + if string(content) != secretValue { + t.Errorf("Secret content = %q, want %q", string(content), secretValue) + } +} + +func TestWriteSecretFile_NoNewline(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "create-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + secretPath := filepath.Join(tmpDir, "test_secret") + secretValue := "password123" + + err = writeSecretFile(secretPath, secretValue) + if err != nil { + t.Fatalf("writeSecretFile failed: %v", err) + } + + content, err := os.ReadFile(secretPath) + if err != nil { + t.Fatalf("Failed to read secret file: %v", err) + } + + // Verify no trailing newline (matching shell's printf behavior) + if string(content) != secretValue { + t.Errorf("Secret should not have trailing newline, got %q", string(content)) + } +} + +func TestWriteSecretFile_Overwrite(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "create-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + secretPath := filepath.Join(tmpDir, "test_secret") + + // Write initial secret + if err := writeSecretFile(secretPath, "old-secret"); err != nil { + t.Fatalf("First writeSecretFile failed: %v", err) + } + + // Overwrite with new secret + newSecret := "new-secret" + if err := writeSecretFile(secretPath, newSecret); err != nil { + t.Fatalf("Second writeSecretFile failed: %v", err) + } + + // Verify new content + content, err := os.ReadFile(secretPath) + if err != nil { + t.Fatalf("Failed to read secret file: %v", err) + } + + if string(content) != newSecret { + t.Errorf("Secret content = %q, want %q", string(content), newSecret) + } +} + +func TestSecureSecretsDirectory(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "create-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + // Create a secrets directory with some files + secretsDir := filepath.Join(tmpDir, "secrets") + if err := os.MkdirAll(secretsDir, 0755); err != nil { + t.Fatalf("Failed to create secrets dir: %v", err) + } + + // Create test secret files with open permissions + testFiles := []string{"secret1", "secret2", "secret3"} + for _, filename := range testFiles { + path := filepath.Join(secretsDir, filename) + if err := os.WriteFile(path, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file %s: %v", filename, err) + } + } + + // Secure the directory + err = secureSecretsDirectory(secretsDir) + if err != nil { + t.Fatalf("secureSecretsDirectory failed: %v", err) + } + + // Verify directory permissions (700) + dirInfo, err := os.Stat(secretsDir) + if err != nil { + t.Fatalf("Failed to stat secrets directory: %v", err) + } + + expectedDirPerms := os.FileMode(0700) + if dirInfo.Mode().Perm() != expectedDirPerms { + t.Errorf("Directory permissions = %v, want %v", dirInfo.Mode().Perm(), expectedDirPerms) + } + + // Verify all file permissions (600) + expectedFilePerms := os.FileMode(0600) + for _, filename := range testFiles { + path := filepath.Join(secretsDir, filename) + fileInfo, err := os.Stat(path) + if err != nil { + t.Fatalf("Failed to stat file %s: %v", filename, err) + } + + if fileInfo.Mode().Perm() != expectedFilePerms { + t.Errorf("File %s permissions = %v, want %v", filename, fileInfo.Mode().Perm(), expectedFilePerms) + } + } +} + +func TestSecureSecretsDirectory_SkipsSubdirectories(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "create-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + // Create secrets directory + secretsDir := filepath.Join(tmpDir, "secrets") + if err := os.MkdirAll(secretsDir, 0755); err != nil { + t.Fatalf("Failed to create secrets dir: %v", err) + } + + // Create a subdirectory within secrets + subDir := filepath.Join(secretsDir, "subdir") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatalf("Failed to create subdirectory: %v", err) + } + + // Create a file + testFile := filepath.Join(secretsDir, "secret1") + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Secure the directory (should skip subdirectory) + err = secureSecretsDirectory(secretsDir) + if err != nil { + t.Fatalf("secureSecretsDirectory failed: %v", err) + } + + // Verify subdirectory permissions were NOT changed + subDirInfo, err := os.Stat(subDir) + if err != nil { + t.Fatalf("Failed to stat subdirectory: %v", err) + } + + // Should still have original 0755 permissions + if subDirInfo.Mode().Perm() == os.FileMode(0600) { + t.Error("Subdirectory permissions should not be changed to 0600") + } + + // Verify file permissions WERE changed + fileInfo, err := os.Stat(testFile) + if err != nil { + t.Fatalf("Failed to stat file: %v", err) + } + + expectedPerms := os.FileMode(0600) + if fileInfo.Mode().Perm() != expectedPerms { + t.Errorf("File permissions = %v, want %v", fileInfo.Mode().Perm(), expectedPerms) + } +} + +func TestCreateInstance(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "create-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + // Create secrets directory + secretsDir := filepath.Join(tmpDir, "secrets") + if err := os.MkdirAll(secretsDir, 0755); err != nil { + t.Fatalf("Failed to create secrets dir: %v", err) + } + + // Create some existing secret files (simulating 'setup' output) + existingSecrets := map[string]string{ + "postgres_password": "old-db-password", + "superadmin": "old-admin-password", + "internal_auth_password": "some-auth-key", + } + + for filename, content := range existingSecrets { + path := filepath.Join(secretsDir, filename) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("Failed to create existing secret %s: %v", filename, err) + } + } + + // Run createInstance + dbPassword := "new-database-password" + superadminPassword := "new-superadmin-password" + + err = createInstance(tmpDir, dbPassword, superadminPassword) + if err != nil { + t.Fatalf("createInstance failed: %v", err) + } + + // Verify postgres_password was overwritten + pgContent, err := os.ReadFile(filepath.Join(secretsDir, pgPasswordFile)) + if err != nil { + t.Fatalf("Failed to read postgres_password: %v", err) + } + if string(pgContent) != dbPassword { + t.Errorf("postgres_password = %q, want %q", string(pgContent), dbPassword) + } + + // Verify superadmin was overwritten + adminContent, err := os.ReadFile(filepath.Join(secretsDir, adminSecretsFile)) + if err != nil { + t.Fatalf("Failed to read superadmin: %v", err) + } + if string(adminContent) != superadminPassword { + t.Errorf("superadmin = %q, want %q", string(adminContent), superadminPassword) + } + + // Verify other secrets were not touched + authContent, err := os.ReadFile(filepath.Join(secretsDir, "internal_auth_password")) + if err != nil { + t.Fatalf("Failed to read internal_auth_password: %v", err) + } + if string(authContent) != existingSecrets["internal_auth_password"] { + t.Errorf("internal_auth_password was unexpectedly changed") + } + + // Verify all files have 0600 permissions + entries, err := os.ReadDir(secretsDir) + if err != nil { + t.Fatalf("Failed to read secrets directory: %v", err) + } + + expectedPerms := os.FileMode(0600) + for _, entry := range entries { + if entry.IsDir() { + continue + } + + path := filepath.Join(secretsDir, entry.Name()) + fileInfo, err := os.Stat(path) + if err != nil { + t.Fatalf("Failed to stat %s: %v", entry.Name(), err) + } + + if fileInfo.Mode().Perm() != expectedPerms { + t.Errorf("File %s permissions = %v, want %v", entry.Name(), fileInfo.Mode().Perm(), expectedPerms) + } + } + + // Verify directory has 0700 permissions + dirInfo, err := os.Stat(secretsDir) + if err != nil { + t.Fatalf("Failed to stat secrets directory: %v", err) + } + + expectedDirPerms := os.FileMode(0700) + if dirInfo.Mode().Perm() != expectedDirPerms { + t.Errorf("Directory permissions = %v, want %v", dirInfo.Mode().Perm(), expectedDirPerms) + } +} + +func TestCreateInstance_SecretsDirectoryNotExist(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "create-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + // Don't create secrets directory - should fail + err = createInstance(tmpDir, "password", "admin") + if err == nil { + t.Error("Expected error when secrets directory doesn't exist, got nil") + } + + // Error message should mention running 'setup' first + expectedMsg := "run 'setup' first" + if err != nil && !contains(err.Error(), expectedMsg) { + t.Errorf("Error should mention running 'setup', got: %v", err) + } +} + +// Helper function to check if string contains substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || hasSubstring(s, substr)) +} + +func hasSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/k8s/actions/health.go b/internal/k8s/actions/health.go new file mode 100644 index 0000000..8ac5c2b --- /dev/null +++ b/internal/k8s/actions/health.go @@ -0,0 +1,163 @@ +package actions + +import ( + "context" + "fmt" + "time" + + "github.com/OpenSlides/openslides-cli/internal/k8s/client" + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/spf13/cobra" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + HealthHelp = "Check health status of an OpenSlides instance" + HealthHelpExtra = `Checks if all pods in the instance namespace are ready and running. + +Examples: + osmanage k8s health --namespace openslides-prod + osmanage k8s health --namespace openslides-test --wait --timeout 5m` +) + +func HealthCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "health", + Short: HealthHelp, + Long: HealthHelp + "\n\n" + HealthHelpExtra, + Args: cobra.NoArgs, + } + + namespace := cmd.Flags().StringP("namespace", "n", "", "Kubernetes namespace (required)") + kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") + wait := cmd.Flags().Bool("wait", false, "Wait for instance to become healthy") + timeout := cmd.Flags().Duration("timeout", 5*time.Minute, "Timeout for wait operation") + + _ = cmd.MarkFlagRequired("namespace") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + logger.Info("=== K8S HEALTH CHECK ===") + logger.Debug("Namespace: %s", *namespace) + + k8sClient, err := client.New(*kubeconfig) + if err != nil { + return fmt.Errorf("creating k8s client: %w", err) + } + + ctx := context.Background() + + if *wait { + return waitForHealthy(ctx, k8sClient, *namespace, *timeout) + } + + return checkHealth(ctx, k8sClient, *namespace) + } + + return cmd +} + +// checkHealth checks the current health status +func checkHealth(ctx context.Context, k8sClient *client.Client, namespace string) error { + pods, err := k8sClient.Clientset().CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("listing pods: %w", err) + } + + totalPods := len(pods.Items) + if totalPods == 0 { + return fmt.Errorf("no pods found in namespace %s", namespace) + } + + readyPods := 0 + fmt.Printf("Namespace: %s\n", namespace) + fmt.Println("Pod Status:") + + for _, pod := range pods.Items { + ready := isPodReady(&pod) + if ready { + readyPods++ + } + + status := "✗" + if ready { + status = "✓" + } + fmt.Printf(" %s %-50s %s\n", status, pod.Name, pod.Status.Phase) + } + + fmt.Printf("\nReady: %d/%d pods\n", readyPods, totalPods) + + if readyPods != totalPods { + return fmt.Errorf("instance is not healthy") + } + + logger.Info("Instance is healthy") + return nil +} + +// waitForHealthy waits for instance to become healthy +func waitForHealthy(ctx context.Context, k8sClient *client.Client, namespace string, timeout time.Duration) error { + logger.Info("Waiting for instance to become healthy (timeout: %v)", timeout) + + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + for { + select { + case <-ticker.C: + healthy, ready, total, err := getHealthStatus(ctx, k8sClient, namespace) + if err != nil { + logger.Debug("Error checking health: %v", err) + continue + } + + logger.Debug("Health check: %d/%d pods ready", ready, total) + + if healthy { + logger.Info("Instance is healthy!") + return nil + } + + case <-timeoutCtx.Done(): + return fmt.Errorf("timeout waiting for instance to become healthy") + } + } +} + +// getHealthStatus returns health metrics +func getHealthStatus(ctx context.Context, k8sClient *client.Client, namespace string) (healthy bool, ready, total int, err error) { + pods, err := k8sClient.Clientset().CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return false, 0, 0, err + } + + total = len(pods.Items) + if total == 0 { + return false, 0, 0, nil + } + + ready = 0 + for _, pod := range pods.Items { + if isPodReady(&pod) { + ready++ + } + } + + healthy = ready == total + return healthy, ready, total, nil +} + +// isPodReady checks if a pod is ready +func isPodReady(pod *corev1.Pod) bool { + for _, condition := range pod.Status.Conditions { + if condition.Type == corev1.PodReady { + return condition.Status == corev1.ConditionTrue + } + } + return false +} diff --git a/internal/k8s/actions/health_test.go b/internal/k8s/actions/health_test.go new file mode 100644 index 0000000..ea194a8 --- /dev/null +++ b/internal/k8s/actions/health_test.go @@ -0,0 +1,62 @@ +package actions + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" +) + +func TestIsPodReady(t *testing.T) { + tests := []struct { + name string + pod *corev1.Pod + expected bool + }{ + { + name: "pod is ready", + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: corev1.ConditionTrue, + }, + }, + }, + }, + expected: true, + }, + { + name: "pod is not ready", + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: corev1.ConditionFalse, + }, + }, + }, + }, + expected: false, + }, + { + name: "pod has no ready condition", + pod: &corev1.Pod{ + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{}, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isPodReady(tt.pod) + if result != tt.expected { + t.Errorf("isPodReady() = %v, want %v", result, tt.expected) + } + }) + } +} diff --git a/internal/k8s/actions/remove.go b/internal/k8s/actions/remove.go new file mode 100644 index 0000000..2296dca --- /dev/null +++ b/internal/k8s/actions/remove.go @@ -0,0 +1,86 @@ +package actions + +import ( + "fmt" + "os" + + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/spf13/cobra" +) + +const ( + RemoveHelp = "Remove an OpenSlides instance directory" + RemoveHelpExtra = `Removes the entire OpenSlides instance directory and all its contents. + +WARNING: This operation is irreversible! All configuration files, secrets, +and instance data in the directory will be permanently deleted. + +Examples: + osmanage k8s remove --project-dir ./my-instance + osmanage k8s remove -d ./old-instance` +) + +func RemoveCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "remove", + Short: RemoveHelp, + Long: RemoveHelp + "\n\n" + RemoveHelpExtra, + Args: cobra.NoArgs, + } + + projectDir := cmd.Flags().StringP("project-dir", "d", "", "Project directory to remove (required)") + force := cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + + _ = cmd.MarkFlagRequired("project-dir") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + logger.Info("=== K8S REMOVE INSTANCE ===") + logger.Debug("Project directory: %s", *projectDir) + + if err := removeInstance(*projectDir, *force); err != nil { + return fmt.Errorf("removing instance: %w", err) + } + + logger.Info("Instance removed successfully") + return nil + } + + return cmd +} + +// removeInstance removes the entire project directory +func removeInstance(projectDir string, force bool) error { + info, err := os.Stat(projectDir) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("%s does not exist", projectDir) + } + return fmt.Errorf("checking directory: %w", err) + } + + if !info.IsDir() { + return fmt.Errorf("%s is not a directory", projectDir) + } + + if !force { + logger.Warn("This will permanently delete: %s", projectDir) + logger.Warn("All configuration files, secrets, and data will be lost!") + + fmt.Print("Are you sure you want to continue? [y/N]: ") + var response string + fmt.Scanln(&response) + + if response != "y" && response != "Y" && response != "yes" && response != "YES" { + logger.Info("Removal cancelled") + return nil + } + } + + logger.Info("Removing instance directory: %s", projectDir) + + if err := os.RemoveAll(projectDir); err != nil { + return fmt.Errorf("removing directory: %w", err) + } + + return nil +} diff --git a/internal/k8s/actions/remove_test.go b/internal/k8s/actions/remove_test.go new file mode 100644 index 0000000..fed3b7e --- /dev/null +++ b/internal/k8s/actions/remove_test.go @@ -0,0 +1,245 @@ +package actions + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRemoveInstance_DirectoryExists(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "remove-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + projectDir := filepath.Join(tmpDir, "test-instance") + if err := os.MkdirAll(projectDir, 0755); err != nil { + t.Fatalf("Failed to create project dir: %v", err) + } + + secretsDir := filepath.Join(projectDir, "secrets") + if err := os.MkdirAll(secretsDir, 0755); err != nil { + t.Fatalf("Failed to create secrets dir: %v", err) + } + + testFile := filepath.Join(secretsDir, "test_secret") + if err := os.WriteFile(testFile, []byte("secret"), 0600); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + err = removeInstance(projectDir, true) + if err != nil { + t.Fatalf("removeInstance failed: %v", err) + } + + if _, err := os.Stat(projectDir); !os.IsNotExist(err) { + t.Errorf("Project directory still exists after removal") + } +} + +func TestRemoveInstance_DirectoryNotExist(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "remove-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + nonExistentDir := filepath.Join(tmpDir, "does-not-exist") + + err = removeInstance(nonExistentDir, true) + if err == nil { + t.Error("Expected error when removing non-existent directory, got nil") + } + + expectedMsg := "does not exist" + if err != nil && !contains(err.Error(), expectedMsg) { + t.Errorf("Error should mention directory doesn't exist, got: %v", err) + } +} + +func TestRemoveInstance_NotADirectory(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "remove-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + testFile := filepath.Join(tmpDir, "test-file") + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + err = removeInstance(testFile, true) + if err == nil { + t.Error("Expected error when removing a file instead of directory, got nil") + } + + expectedMsg := "not a directory" + if err != nil && !contains(err.Error(), expectedMsg) { + t.Errorf("Error should mention it's not a directory, got: %v", err) + } +} + +func TestRemoveInstance_RemovesNestedStructure(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "remove-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + projectDir := filepath.Join(tmpDir, "complex-instance") + + dirs := []string{ + filepath.Join(projectDir, "secrets"), + filepath.Join(projectDir, "config"), + filepath.Join(projectDir, "data", "postgres"), + filepath.Join(projectDir, "data", "redis"), + } + + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("Failed to create dir %s: %v", dir, err) + } + } + + files := []string{ + filepath.Join(projectDir, "secrets", "postgres_password"), + filepath.Join(projectDir, "secrets", "superadmin"), + filepath.Join(projectDir, "config", "docker-compose.yml"), + filepath.Join(projectDir, "data", "postgres", "pg_data.db"), + filepath.Join(projectDir, "data", "redis", "dump.rdb"), + } + + for _, file := range files { + if err := os.WriteFile(file, []byte("test data"), 0644); err != nil { + t.Fatalf("Failed to create file %s: %v", file, err) + } + } + + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + err = removeInstance(projectDir, true) + if err != nil { + t.Fatalf("removeInstance failed: %v", err) + } + + if _, err := os.Stat(projectDir); !os.IsNotExist(err) { + t.Error("Project directory still exists after removal") + } + + if _, err := os.Stat(tmpDir); os.IsNotExist(err) { + t.Error("Parent directory should still exist") + } +} + +func TestRemoveInstance_WithForceFlag(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "remove-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + projectDir := filepath.Join(tmpDir, "test-instance") + if err := os.MkdirAll(projectDir, 0755); err != nil { + t.Fatalf("Failed to create project dir: %v", err) + } + + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + err = removeInstance(projectDir, true) + if err != nil { + t.Fatalf("removeInstance with force=true failed: %v", err) + } + + if _, err := os.Stat(projectDir); !os.IsNotExist(err) { + t.Error("Project directory still exists after removal") + } +} + +func TestRemoveInstance_EmptyDirectory(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "remove-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + projectDir := filepath.Join(tmpDir, "empty-instance") + if err := os.MkdirAll(projectDir, 0755); err != nil { + t.Fatalf("Failed to create project dir: %v", err) + } + + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + err = removeInstance(projectDir, true) + if err != nil { + t.Fatalf("removeInstance failed on empty directory: %v", err) + } + + if _, err := os.Stat(projectDir); !os.IsNotExist(err) { + t.Error("Empty directory still exists after removal") + } +} + +func TestRemoveInstance_WithSymlinks(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "remove-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + projectDir := filepath.Join(tmpDir, "test-instance") + if err := os.MkdirAll(projectDir, 0755); err != nil { + t.Fatalf("Failed to create project dir: %v", err) + } + + targetFile := filepath.Join(tmpDir, "target.txt") + if err := os.WriteFile(targetFile, []byte("target"), 0644); err != nil { + t.Fatalf("Failed to create target file: %v", err) + } + + symlinkPath := filepath.Join(projectDir, "link") + if err := os.Symlink(targetFile, symlinkPath); err != nil { + t.Fatalf("Failed to create symlink: %v", err) + } + + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + err = removeInstance(projectDir, true) + if err != nil { + t.Fatalf("removeInstance failed: %v", err) + } + + if _, err := os.Stat(projectDir); !os.IsNotExist(err) { + t.Error("Project directory still exists after removal") + } + + if _, err := os.Stat(targetFile); os.IsNotExist(err) { + t.Error("Symlink target should not be deleted") + } +} diff --git a/internal/k8s/actions/start.go b/internal/k8s/actions/start.go new file mode 100644 index 0000000..1077dd1 --- /dev/null +++ b/internal/k8s/actions/start.go @@ -0,0 +1,212 @@ +package actions + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/OpenSlides/openslides-cli/internal/k8s/client" + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/spf13/cobra" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/restmapper" +) + +const ( + StartHelp = "Start an OpenSlides instance" + StartHelpExtra = `Applies Kubernetes manifests to start an OpenSlides instance. + +Examples: + osmanage k8s start ./my-instance + osmanage k8s start ./my-instance --skip-ready-check + osmanage k8s start ./my-instance --kubeconfig ~/.kube/config` + + tlsCertSecretYAML = "secrets/tls-letsencrypt-secret.yaml" +) + +func StartCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "start ", + Short: StartHelp, + Long: StartHelp + "\n\n" + StartHelpExtra, + Args: cobra.ExactArgs(1), + } + + kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") + skipReadyCheck := cmd.Flags().Bool("skip-ready-check", false, "Skip waiting for instance to become ready") + timeout := cmd.Flags().Duration("timeout", 5*time.Minute, "Timeout for ready check") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + projectDir := args[0] + + logger.Info("=== K8S START INSTANCE ===") + logger.Debug("Project directory: %s", projectDir) + + k8sClient, err := client.New(*kubeconfig) + if err != nil { + return fmt.Errorf("creating k8s client: %w", err) + } + + ctx := context.Background() + + namespacePath := filepath.Join(projectDir, "namespace.yaml") + namespace, err := applyManifest(ctx, k8sClient, namespacePath) + if err != nil { + return fmt.Errorf("applying namespace: %w", err) + } + logger.Info("Applied namespace: %s", namespace) + + tlsSecretPath := filepath.Join(projectDir, tlsCertSecretYAML) + if fileExists(tlsSecretPath) { + logger.Info("Found and applying %s", tlsCertSecretYAML) + if _, err := applyManifest(ctx, k8sClient, tlsSecretPath); err != nil { + return fmt.Errorf("applying TLS secret: %w", err) + } + } + + stackDir := filepath.Join(projectDir, "stack") + logger.Info("Applying stack manifests from: %s", stackDir) + if err := applyDirectory(ctx, k8sClient, stackDir); err != nil { + return fmt.Errorf("applying stack: %w", err) + } + + if *skipReadyCheck { + logger.Info("Skipping ready check") + return nil + } + + logger.Info("Waiting for instance to become ready...") + if err := waitForHealthy(ctx, k8sClient, namespace, *timeout); err != nil { + return fmt.Errorf("waiting for ready: %w", err) + } + + logger.Info("Instance started successfully") + return nil + } + + return cmd +} + +// applyManifest applies a single YAML manifest file using RESTMapper +func applyManifest(ctx context.Context, k8sClient *client.Client, manifestPath string) (string, error) { + logger.Debug("Applying manifest: %s", manifestPath) + + data, err := os.ReadFile(manifestPath) + if err != nil { + return "", fmt.Errorf("reading manifest: %w", err) + } + + var obj unstructured.Unstructured + if err := yaml.Unmarshal(data, &obj); err != nil { + return "", fmt.Errorf("parsing YAML: %w", err) + } + + namespace := obj.GetNamespace() + if namespace == "" && obj.GetKind() == "Namespace" { + namespace = obj.GetName() + } + + discoveryClient, err := discovery.NewDiscoveryClientForConfig(k8sClient.Config()) + if err != nil { + return "", fmt.Errorf("creating discovery client: %w", err) + } + + apiGroupResources, err := restmapper.GetAPIGroupResources(discoveryClient) + if err != nil { + return "", fmt.Errorf("getting API group resources: %w", err) + } + + mapper := restmapper.NewDiscoveryRESTMapper(apiGroupResources) + + gvk := obj.GroupVersionKind() + + mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return "", fmt.Errorf("getting REST mapping for %s: %w", gvk.String(), err) + } + + dynamicClient, err := dynamic.NewForConfig(k8sClient.Config()) + if err != nil { + return "", fmt.Errorf("creating dynamic client: %w", err) + } + + var result *unstructured.Unstructured + if mapping.Scope.Name() == meta.RESTScopeNameNamespace { + if namespace == "" { + namespace = "default" + } + result, err = dynamicClient.Resource(mapping.Resource).Namespace(namespace).Apply( + ctx, + obj.GetName(), + &obj, + metav1.ApplyOptions{ + FieldManager: "osmanage", + Force: true, + }, + ) + } else { + result, err = dynamicClient.Resource(mapping.Resource).Apply( + ctx, + obj.GetName(), + &obj, + metav1.ApplyOptions{ + FieldManager: "osmanage", + Force: true, + }, + ) + } + + if err != nil { + return namespace, fmt.Errorf("applying %s/%s: %w", obj.GetKind(), obj.GetName(), err) + } + + logger.Info("Applied %s: %s", result.GetKind(), result.GetName()) + return namespace, nil +} + +// applyDirectory applies all YAML files in a directory +func applyDirectory(ctx context.Context, k8sClient *client.Client, dirPath string) error { + files, err := os.ReadDir(dirPath) + if err != nil { + return fmt.Errorf("reading directory: %w", err) + } + + for _, file := range files { + if file.IsDir() { + continue + } + + if !isYAMLFile(file.Name()) { + logger.Debug("Skipping non-YAML file: %s", file.Name()) + continue + } + + manifestPath := filepath.Join(dirPath, file.Name()) + if _, err := applyManifest(ctx, k8sClient, manifestPath); err != nil { + logger.Warn("Failed to apply %s: %v", file.Name(), err) + continue + } + } + + return nil +} + +// fileExists checks if a file exists +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// isYAMLFile checks if filename has YAML extension +func isYAMLFile(filename string) bool { + ext := filepath.Ext(filename) + return ext == ".yaml" || ext == ".yml" +} diff --git a/internal/k8s/actions/start_test.go b/internal/k8s/actions/start_test.go new file mode 100644 index 0000000..6a13ca8 --- /dev/null +++ b/internal/k8s/actions/start_test.go @@ -0,0 +1,294 @@ +package actions + +import ( + "os" + "path/filepath" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/yaml" +) + +func TestFileExists(t *testing.T) { + tests := []struct { + name string + path string + expected bool + setup func(t *testing.T) string + cleanup func(path string) + }{ + { + name: "file exists", + expected: true, + setup: func(t *testing.T) string { + tmpFile := filepath.Join(t.TempDir(), "test.yaml") + if err := os.WriteFile(tmpFile, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + return tmpFile + }, + }, + { + name: "file does not exist", + path: "/nonexistent/path/file.yaml", + expected: false, + }, + { + name: "empty path", + path: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.path + if tt.setup != nil { + path = tt.setup(t) + } + + result := fileExists(path) + if result != tt.expected { + t.Errorf("fileExists(%q) = %v, want %v", path, result, tt.expected) + } + }) + } +} + +func TestIsYAMLFile(t *testing.T) { + tests := []struct { + name string + filename string + expected bool + }{ + {"yaml extension", "deployment.yaml", true}, + {"yml extension", "service.yml", true}, + {"json file", "config.json", false}, + {"txt file", "readme.txt", false}, + {"no extension", "Dockerfile", false}, + {"multiple dots yaml", "my.config.yaml", true}, + {"multiple dots yml", "my.config.yml", true}, + {"uppercase YAML", "file.YAML", false}, // filepath.Ext is case-sensitive + {"empty string", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isYAMLFile(tt.filename) + if result != tt.expected { + t.Errorf("isYAMLFile(%q) = %v, want %v", tt.filename, result, tt.expected) + } + }) + } +} + +func TestApplyDirectory_FileFiltering(t *testing.T) { + // Create temp directory with mixed files + tmpDir := t.TempDir() + + // Create test files + files := map[string]string{ + "deployment.yaml": "apiVersion: apps/v1\nkind: Deployment", + "service.yml": "apiVersion: v1\nkind: Service", + "config.json": `{"key": "value"}`, + "README.md": "# Documentation", + "script.sh": "#!/bin/bash", + ".hidden.yaml": "apiVersion: v1\nkind: Secret", + "nested/deep.yaml": "apiVersion: v1\nkind: ConfigMap", + } + + yamlCount := 0 + for filename, content := range files { + path := filepath.Join(tmpDir, filename) + + // Create subdirectory if needed + dir := filepath.Dir(path) + if dir != tmpDir { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + } + + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + // Count YAML files + if isYAMLFile(filename) { + yamlCount++ + } + } + + // Read directory and verify filtering logic + entries, err := os.ReadDir(tmpDir) + if err != nil { + t.Fatal(err) + } + + filteredCount := 0 + for _, entry := range entries { + if entry.IsDir() { + continue + } + if isYAMLFile(entry.Name()) { + filteredCount++ + } + } + + // We expect 3 YAML files at top level: deployment.yaml, service.yml, .hidden.yaml + expectedYAMLFiles := 3 + if filteredCount != expectedYAMLFiles { + t.Errorf("Expected %d YAML files, found %d", expectedYAMLFiles, filteredCount) + } +} + +func TestApplyManifest_NamespaceExtraction(t *testing.T) { + tests := []struct { + name string + manifestContent string + expectedNamespace string + expectError bool + }{ + { + name: "namespace resource", + manifestContent: `apiVersion: v1 +kind: Namespace +metadata: + name: test-namespace`, + expectedNamespace: "test-namespace", + expectError: false, + }, + { + name: "namespaced resource with namespace", + manifestContent: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: test-deployment + namespace: my-namespace`, + expectedNamespace: "my-namespace", + expectError: false, + }, + { + name: "namespaced resource without namespace", + manifestContent: `apiVersion: v1 +kind: Service +metadata: + name: test-service`, + expectedNamespace: "", + expectError: false, + }, + { + name: "invalid yaml", + manifestContent: "not: valid: yaml: content:", + expectedNamespace: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Parse YAML + var obj unstructured.Unstructured + err := yaml.Unmarshal([]byte(tt.manifestContent), &obj) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Extract namespace + namespace := obj.GetNamespace() + if namespace == "" && obj.GetKind() == "Namespace" { + namespace = obj.GetName() + } + + if namespace != tt.expectedNamespace { + t.Errorf("Expected namespace %q, got %q", tt.expectedNamespace, namespace) + } + }) + } +} + +func TestTLSSecretPath(t *testing.T) { + // Verify the constant matches expected path + expected := "secrets/tls-letsencrypt-secret.yaml" + if tlsCertSecretYAML != expected { + t.Errorf("tlsCertSecretYAML = %q, want %q", tlsCertSecretYAML, expected) + } + + // Test path construction + projectDir := "/path/to/project" + fullPath := filepath.Join(projectDir, tlsCertSecretYAML) + expectedPath := "/path/to/project/secrets/tls-letsencrypt-secret.yaml" + + if fullPath != expectedPath { + t.Errorf("Path construction failed: got %q, want %q", fullPath, expectedPath) + } +} + +func TestStartCmd_Flags(t *testing.T) { + cmd := StartCmd() + + // Verify command exists + if cmd == nil { + t.Fatal("StartCmd() returned nil") + } + + // Verify command name + if cmd.Use != "start " { + t.Errorf("Command use = %q, want %q", cmd.Use, "start ") + } + + // Verify flags exist + tests := []struct { + flagName string + defaultValue string + }{ + {"kubeconfig", ""}, + {"skip-ready-check", "false"}, + {"timeout", "5m0s"}, + } + + for _, tt := range tests { + t.Run(tt.flagName, func(t *testing.T) { + flag := cmd.Flags().Lookup(tt.flagName) + if flag == nil { + t.Fatalf("Flag %q not found", tt.flagName) + } + + if flag.DefValue != tt.defaultValue { + t.Errorf("Flag %q default = %q, want %q", tt.flagName, flag.DefValue, tt.defaultValue) + } + }) + } +} + +func TestStartCmd_Args(t *testing.T) { + cmd := StartCmd() + + // Test with no args (should fail) + cmd.SetArgs([]string{}) + err := cmd.Execute() + if err == nil { + t.Error("Expected error with no args, got nil") + } + + // Test with correct number of args (should pass validation, may fail execution) + cmd.SetArgs([]string{"./test-dir"}) + // We don't execute because it would try to connect to k8s + // Just verify args are accepted + if err := cmd.Args(cmd, []string{"./test-dir"}); err != nil { + t.Errorf("Args validation failed with valid args: %v", err) + } + + // Test with too many args (should fail) + if err := cmd.Args(cmd, []string{"./test-dir", "extra"}); err == nil { + t.Error("Expected error with too many args, got nil") + } +} diff --git a/internal/k8s/actions/stop.go b/internal/k8s/actions/stop.go new file mode 100644 index 0000000..94e9527 --- /dev/null +++ b/internal/k8s/actions/stop.go @@ -0,0 +1,147 @@ +package actions + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/OpenSlides/openslides-cli/internal/k8s/client" + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/spf13/cobra" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +const ( + StopHelp = "Stop an OpenSlides instance" + StopHelpExtra = `Stops an OpenSlides instance by deleting its namespace. +If a TLS certificate secret exists, it will be saved before deletion. + +Examples: + osmanage k8s stop --namespace openslides-prod --project-dir ./my-instance + osmanage k8s stop -n openslides-test --project-dir ./test-instance` + + tlsCertSecret = "tls-letsencrypt" +) + +func StopCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "stop", + Short: StopHelp, + Long: StopHelp + "\n\n" + StopHelpExtra, + Args: cobra.NoArgs, + } + + namespace := cmd.Flags().StringP("namespace", "n", "", "Kubernetes namespace to delete (required)") + projectDir := cmd.Flags().StringP("project-dir", "d", "", "Project directory to save TLS secret (required)") + kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") + + _ = cmd.MarkFlagRequired("namespace") + _ = cmd.MarkFlagRequired("project-dir") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + logger.Info("=== K8S STOP INSTANCE ===") + logger.Debug("Namespace: %s", *namespace) + logger.Debug("Project directory: %s", *projectDir) + + k8sClient, err := client.New(*kubeconfig) + if err != nil { + return fmt.Errorf("creating k8s client: %w", err) + } + + ctx := context.Background() + + if err := saveTLSSecret(ctx, k8sClient, *namespace, *projectDir); err != nil { + logger.Warn("Failed to save TLS secret: %v", err) + } + + logger.Info("Stopping instance: %s", *namespace) + if err := deleteNamespace(ctx, k8sClient, *namespace); err != nil { + return fmt.Errorf("deleting namespace: %w", err) + } + + logger.Info("Instance stopped successfully") + return nil + } + + return cmd +} + +// saveTLSSecret saves the TLS certificate secret to a YAML file if it exists +func saveTLSSecret(ctx context.Context, k8sClient *client.Client, namespace, projectDir string) error { + clientset := k8sClient.Clientset() + + secret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, tlsCertSecret, metav1.GetOptions{}) + if err != nil { + logger.Debug("TLS secret %s not found in namespace %s", tlsCertSecret, namespace) + return nil + } + + logger.Info("Found %s secret. Saving to %s", tlsCertSecret, tlsCertSecretYAML) + + secretYAML, err := yaml.Marshal(secret) + if err != nil { + return fmt.Errorf("marshaling secret to YAML: %w", err) + } + + secretsDir := filepath.Join(projectDir, "secrets") + if err := os.MkdirAll(secretsDir, 0755); err != nil { + return fmt.Errorf("creating secrets directory: %w", err) + } + + secretPath := filepath.Join(projectDir, tlsCertSecretYAML) + if err := os.WriteFile(secretPath, secretYAML, 0600); err != nil { + return fmt.Errorf("writing secret file: %w", err) + } + + logger.Info("Saved TLS secret to: %s", secretPath) + return nil +} + +// deleteNamespace deletes a Kubernetes namespace +func deleteNamespace(ctx context.Context, k8sClient *client.Client, namespace string) error { + clientset := k8sClient.Clientset() + + logger.Debug("Deleting namespace: %s", namespace) + + deletePolicy := metav1.DeletePropagationForeground + err := clientset.CoreV1().Namespaces().Delete(ctx, namespace, metav1.DeleteOptions{ + PropagationPolicy: &deletePolicy, + }) + if err != nil { + return fmt.Errorf("deleting namespace %s: %w", namespace, err) + } + + logger.Info("Namespace %s deletion initiated", namespace) + + logger.Debug("Waiting for namespace to be fully deleted...") + return waitForNamespaceDeletion(ctx, k8sClient, namespace) +} + +// waitForNamespaceDeletion waits for a namespace to be completely deleted +func waitForNamespaceDeletion(ctx context.Context, k8sClient *client.Client, namespace string) error { + clientset := k8sClient.Clientset() + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + timeout := time.After(5 * time.Minute) + + for { + select { + case <-ticker.C: + _, err := clientset.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) + if err != nil { + logger.Debug("Namespace %s successfully deleted", namespace) + return nil + } + logger.Debug("Namespace %s still terminating...", namespace) + + case <-timeout: + return fmt.Errorf("timeout waiting for namespace %s to be deleted", namespace) + } + } +} diff --git a/internal/k8s/actions/stop_test.go b/internal/k8s/actions/stop_test.go new file mode 100644 index 0000000..29279fa --- /dev/null +++ b/internal/k8s/actions/stop_test.go @@ -0,0 +1,308 @@ +package actions + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" + "sigs.k8s.io/yaml" +) + +func TestSaveTLSSecret_SecretExists(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: tlsCertSecret, + Namespace: "test-namespace", + }, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nfake-cert-data\n-----END CERTIFICATE-----"), + "tls.key": []byte("-----BEGIN PRIVATE KEY-----\nfake-key-data\n-----END PRIVATE KEY-----"), + }, + } + + fakeClient := fake.NewClientset(secret) + + tmpDir, err := os.MkdirTemp("", "stop-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + ctx := context.Background() + + err = saveTLSSecretWithClientset(ctx, fakeClient, "test-namespace", tmpDir) + if err != nil { + t.Fatalf("saveTLSSecret failed: %v", err) + } + + secretPath := filepath.Join(tmpDir, tlsCertSecretYAML) + if _, err := os.Stat(secretPath); os.IsNotExist(err) { + t.Errorf("Secret file was not created at %s", secretPath) + } + + fileInfo, err := os.Stat(secretPath) + if err != nil { + t.Fatalf("Failed to stat secret file: %v", err) + } + + expectedPerms := os.FileMode(0600) + if fileInfo.Mode().Perm() != expectedPerms { + t.Errorf("Secret file permissions = %v, want %v", fileInfo.Mode().Perm(), expectedPerms) + } + + fileData, err := os.ReadFile(secretPath) + if err != nil { + t.Fatalf("Failed to read secret file: %v", err) + } + + var savedSecret corev1.Secret + if err := yaml.Unmarshal(fileData, &savedSecret); err != nil { + t.Fatalf("Failed to unmarshal saved secret: %v", err) + } + + if string(savedSecret.Data["tls.crt"]) != string(secret.Data["tls.crt"]) { + t.Error("Saved secret tls.crt data doesn't match original") + } + if string(savedSecret.Data["tls.key"]) != string(secret.Data["tls.key"]) { + t.Error("Saved secret tls.key data doesn't match original") + } +} + +func TestSaveTLSSecret_SecretNotExists(t *testing.T) { + fakeClient := fake.NewClientset() + + tmpDir, err := os.MkdirTemp("", "stop-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + ctx := context.Background() + + err = saveTLSSecretWithClientset(ctx, fakeClient, "test-namespace", tmpDir) + if err != nil { + t.Errorf("saveTLSSecret should not fail when secret doesn't exist, got: %v", err) + } + + secretPath := filepath.Join(tmpDir, tlsCertSecretYAML) + if _, err := os.Stat(secretPath); !os.IsNotExist(err) { + t.Error("Secret file should not be created when secret doesn't exist") + } +} + +func TestSaveTLSSecret_CreatesSecretsDirectory(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: tlsCertSecret, + Namespace: "test-namespace", + }, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{ + "tls.crt": []byte("cert"), + "tls.key": []byte("key"), + }, + } + + fakeClient := fake.NewClientset(secret) + + tmpDir, err := os.MkdirTemp("", "stop-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(tmpDir); err != nil { + t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) + } + }) + + ctx := context.Background() + + err = saveTLSSecretWithClientset(ctx, fakeClient, "test-namespace", tmpDir) + if err != nil { + t.Fatalf("saveTLSSecret failed: %v", err) + } + + secretsDir := filepath.Join(tmpDir, "secrets") + if _, err := os.Stat(secretsDir); os.IsNotExist(err) { + t.Error("Secrets directory was not created") + } +} + +func TestDeleteNamespace_Success(t *testing.T) { + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-namespace", + }, + } + + fakeClient := fake.NewClientset(namespace) + + deleted := false + fakeClient.PrependReactor("delete", "namespaces", func(action k8stesting.Action) (bool, runtime.Object, error) { + deleted = true + return true, nil, nil + }) + + fakeClient.PrependReactor("get", "namespaces", func(action k8stesting.Action) (bool, runtime.Object, error) { + if deleted { + return true, nil, apierrors.NewNotFound(corev1.Resource("namespaces"), "test-namespace") + } + return false, namespace, nil + }) + + ctx := context.Background() + + err := deleteNamespaceWithClientset(ctx, fakeClient, "test-namespace") + if err != nil { + t.Errorf("deleteNamespace failed: %v", err) + } + + if !deleted { + t.Error("Namespace was not deleted") + } +} + +func TestDeleteNamespace_WaitsForDeletion(t *testing.T) { + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-namespace", + }, + } + + fakeClient := fake.NewClientset(namespace) + + deleteTime := time.Time{} + deletionStarted := false + + fakeClient.PrependReactor("delete", "namespaces", func(action k8stesting.Action) (bool, runtime.Object, error) { + deleteTime = time.Now() + deletionStarted = true + return true, nil, nil + }) + + checkCount := 0 + fakeClient.PrependReactor("get", "namespaces", func(action k8stesting.Action) (bool, runtime.Object, error) { + checkCount++ + + if !deletionStarted { + return false, namespace, nil + } + + if checkCount <= 3 { + terminatingNs := namespace.DeepCopy() + now := metav1.NewTime(deleteTime) + terminatingNs.DeletionTimestamp = &now + return true, terminatingNs, nil + } + + return true, nil, apierrors.NewNotFound(corev1.Resource("namespaces"), "test-namespace") + }) + + ctx := context.Background() + + start := time.Now() + err := deleteNamespaceWithClientset(ctx, fakeClient, "test-namespace") + duration := time.Since(start) + + if err != nil { + t.Errorf("deleteNamespace failed: %v", err) + } + + if checkCount < 3 { + t.Errorf("Should have checked namespace status multiple times, got %d checks", checkCount) + } + + if duration < 2*time.Second { + t.Errorf("Should have waited for namespace deletion, took only %v", duration) + } +} + +func TestDeleteNamespace_NotFound(t *testing.T) { + fakeClient := fake.NewClientset() + + ctx := context.Background() + + err := deleteNamespaceWithClientset(ctx, fakeClient, "non-existent") + if err != nil { + t.Errorf("deleteNamespace should not fail for non-existent namespace, got: %v", err) + } +} + +// Helper function to test saveTLSSecret with a fake clientset +// This bypasses the client.Client wrapper for testing purposes +func saveTLSSecretWithClientset(ctx context.Context, clientset *fake.Clientset, namespace, projectDir string) error { + secret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, tlsCertSecret, metav1.GetOptions{}) + if err != nil { + return nil + } + + secretsDir := filepath.Join(projectDir, "secrets") + if err := os.MkdirAll(secretsDir, 0755); err != nil { + return err + } + + secretYAML, err := yaml.Marshal(secret) + if err != nil { + return err + } + + secretPath := filepath.Join(projectDir, tlsCertSecretYAML) + if err := os.WriteFile(secretPath, secretYAML, 0600); err != nil { + return err + } + + return nil +} + +// Helper function to test deleteNamespace with a fake clientset +func deleteNamespaceWithClientset(ctx context.Context, clientset *fake.Clientset, namespace string) error { + deletePolicy := metav1.DeletePropagationForeground + deleteOptions := metav1.DeleteOptions{ + PropagationPolicy: &deletePolicy, + } + + err := clientset.CoreV1().Namespaces().Delete(ctx, namespace, deleteOptions) + if err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return err + } + + timeout := time.After(5 * time.Minute) + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-timeout: + return fmt.Errorf("timeout waiting for namespace %s to be deleted", namespace) + case <-ticker.C: + _, err := clientset.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + return nil + } + if err != nil { + return err + } + } + } +} diff --git a/internal/k8s/actions/update_backendmanage.go b/internal/k8s/actions/update_backendmanage.go new file mode 100644 index 0000000..2eb09e4 --- /dev/null +++ b/internal/k8s/actions/update_backendmanage.go @@ -0,0 +1,71 @@ +package actions + +import ( + "context" + "fmt" + "strings" + + "github.com/OpenSlides/openslides-cli/internal/k8s/client" + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/spf13/cobra" +) + +const ( + UpdateBackendmanageHelp = "Updates an OpenSlides instance's backendmanage service." + UpdateBackendmanageHelpExtra = `Updates the backendmanage service deployment image tag and registry to new version. + +Examples: + osmanage k8s update-backendmanage ./my-instance --kubeconfig ~/.kube/config` + + managementBackend = "backendmanage" +) + +func UpdateBackendmanageCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "update-backendmanage ", + Short: StartHelp, + Long: StartHelp + "\n\n" + StartHelpExtra, + Args: cobra.ExactArgs(1), + } + + kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") + tag := cmd.Flags().StringP("tag", "t", "", "OpenSlides backendmanage service image tag") + containerRegistry := cmd.Flags().String("containerRegistry", "", "OpenSlides backendmanage image ContainerRegistry") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + projectDir := args[0] + + logger.Info("=== K8S UPDATE BACKENDMANAGE ===") + + k8sClient, err := client.New(*kubeconfig) + if err != nil { + return fmt.Errorf("creating k8s client: %w", err) + } + + ctx := context.Background() + + namespace := strings.ReplaceAll(projectDir, ".", "") + logger.Info("Create namespace string: %s", namespace) + + err := updateBackendmanage(ctx, k8sClient, namespace, tag, containerRegistry) + if err != nil { + return fmt.Errorf("updating backendmanage service: %w", err) + } + } + + return cmd +} + +func updateBackendmanage(ctx context.Context, k8sClient *client.Client, namespace, tag, containerRegistry string) error { + patch := fmt.Sprintf(`{"spec":{"template":{"spec":{"containers":[{"name":"%s","image":"%s"}]}}}}`, container, image) + + _, err := clientset.AppsV1().Deployments(namespace).Patch( + ctx, + deployment, + types.StrategicMergePatchType, + []byte(patch), + metav1.PatchOptions{}, + ) + + return err +} diff --git a/internal/k8s/client/client.go b/internal/k8s/client/client.go new file mode 100644 index 0000000..e2fb701 --- /dev/null +++ b/internal/k8s/client/client.go @@ -0,0 +1,53 @@ +package client + +import ( + "fmt" + "os" + "path/filepath" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +type Client struct { + clientset *kubernetes.Clientset + config *rest.Config +} + +// New creates a Kubernetes client +func New(kubeconfigPath string) (*Client, error) { + var config *rest.Config + var err error + + config, err = rest.InClusterConfig() + if err != nil { + if kubeconfigPath == "" { + kubeconfigPath = filepath.Join(os.Getenv("HOME"), ".kube", "config") + } + config, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath) + if err != nil { + return nil, fmt.Errorf("failed to create k8s config: %w", err) + } + } + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create k8s client: %w", err) + } + + return &Client{ + clientset: clientset, + config: config, + }, nil +} + +// Clientset returns the underlying Kubernetes clientset +func (c *Client) Clientset() *kubernetes.Clientset { + return c.clientset +} + +// Config returns the underlying Kubernetes config +func (c *Client) Config() *rest.Config { + return c.config +} From e24861540675e8c1acd9740b7af30accfdc84458 Mon Sep 17 00:00:00 2001 From: aantoni Date: Wed, 21 Jan 2026 14:11:46 +0100 Subject: [PATCH 02/28] Fix go.mod --- go.mod | 2 +- go.sum | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 089f6b4..cfc45eb 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( k8s.io/api v0.35.0 k8s.io/apimachinery v0.35.0 k8s.io/client-go v0.35.0 + sigs.k8s.io/yaml v1.6.0 ) require ( @@ -53,7 +54,6 @@ require ( sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect ) require ( diff --git a/go.sum b/go.sum index 02f8dc7..a8ff340 100644 --- a/go.sum +++ b/go.sum @@ -152,8 +152,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= @@ -164,12 +164,12 @@ golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From f06a0b7699b7707870c3d92b2985d721835b4a7f Mon Sep 17 00:00:00 2001 From: aantoni Date: Fri, 23 Jan 2026 16:19:25 +0100 Subject: [PATCH 03/28] Refactor actions, move utility functions to seperate scripts --- cmd/osmanage/main.go | 1 + internal/k8s/actions/apply.go | 122 ++++++++ internal/k8s/actions/cluster_status.go | 61 ++-- internal/k8s/actions/cluster_status_test.go | 303 ++++++------------ internal/k8s/actions/create.go | 15 +- internal/k8s/actions/health.go | 69 +---- internal/k8s/actions/health_check.go | 130 ++++++++ internal/k8s/actions/health_check_test.go | 56 ++++ internal/k8s/actions/health_test.go | 62 ---- internal/k8s/actions/helpers.go | 27 ++ internal/k8s/actions/helpers_test.go | 123 ++++++++ internal/k8s/actions/remove.go | 15 +- internal/k8s/actions/scale.go | 84 +++++ internal/k8s/actions/start.go | 127 +------- internal/k8s/actions/start_test.go | 294 ------------------ internal/k8s/actions/stop.go | 26 +- internal/k8s/actions/stop_test.go | 308 ------------------- internal/k8s/actions/update_backendmanage.go | 142 +++++++-- internal/k8s/actions/update_instance.go | 88 ++++++ internal/k8s/client/client.go | 32 +- 20 files changed, 922 insertions(+), 1163 deletions(-) create mode 100644 internal/k8s/actions/apply.go create mode 100644 internal/k8s/actions/health_check.go create mode 100644 internal/k8s/actions/health_check_test.go delete mode 100644 internal/k8s/actions/health_test.go create mode 100644 internal/k8s/actions/helpers.go create mode 100644 internal/k8s/actions/helpers_test.go create mode 100644 internal/k8s/actions/scale.go delete mode 100644 internal/k8s/actions/start_test.go delete mode 100644 internal/k8s/actions/stop_test.go create mode 100644 internal/k8s/actions/update_instance.go diff --git a/cmd/osmanage/main.go b/cmd/osmanage/main.go index b4cb6a1..9e1b595 100644 --- a/cmd/osmanage/main.go +++ b/cmd/osmanage/main.go @@ -76,6 +76,7 @@ func RootCmd() *cobra.Command { k8sActions.CreateCmd(), k8sActions.HealthCmd(), k8sActions.ClusterStatusCmd(), + k8sActions.UpdateBackendmanageCmd(), ) rootCmd.AddCommand( diff --git a/internal/k8s/actions/apply.go b/internal/k8s/actions/apply.go new file mode 100644 index 0000000..083863c --- /dev/null +++ b/internal/k8s/actions/apply.go @@ -0,0 +1,122 @@ +package actions + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/OpenSlides/openslides-cli/internal/k8s/client" + "github.com/OpenSlides/openslides-cli/internal/logger" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/restmapper" +) + +// applyManifest applies a single YAML manifest file using RESTMapper +func applyManifest(ctx context.Context, k8sClient *client.Client, manifestPath string) (string, error) { + logger.Debug("Applying manifest: %s", manifestPath) + + data, err := os.ReadFile(manifestPath) + if err != nil { + return "", fmt.Errorf("reading manifest: %w", err) + } + + var obj unstructured.Unstructured + if err := yaml.Unmarshal(data, &obj); err != nil { + return "", fmt.Errorf("parsing YAML: %w", err) + } + + namespace := obj.GetNamespace() + if namespace == "" && obj.GetKind() == "Namespace" { + namespace = obj.GetName() + } + + discoveryClient, err := discovery.NewDiscoveryClientForConfig(k8sClient.Config()) + if err != nil { + return "", fmt.Errorf("creating discovery client: %w", err) + } + + apiGroupResources, err := restmapper.GetAPIGroupResources(discoveryClient) + if err != nil { + return "", fmt.Errorf("getting API group resources: %w", err) + } + + mapper := restmapper.NewDiscoveryRESTMapper(apiGroupResources) + gvk := obj.GroupVersionKind() + + mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return "", fmt.Errorf("getting REST mapping for %s: %w", gvk.String(), err) + } + + dynamicClient, err := dynamic.NewForConfig(k8sClient.Config()) + if err != nil { + return "", fmt.Errorf("creating dynamic client: %w", err) + } + + var result *unstructured.Unstructured + if mapping.Scope.Name() == meta.RESTScopeNameNamespace { + if namespace == "" { + namespace = "default" + } + result, err = dynamicClient.Resource(mapping.Resource).Namespace(namespace).Apply( + ctx, + obj.GetName(), + &obj, + metav1.ApplyOptions{ + FieldManager: "osmanage", + Force: true, + }, + ) + } else { + result, err = dynamicClient.Resource(mapping.Resource).Apply( + ctx, + obj.GetName(), + &obj, + metav1.ApplyOptions{ + FieldManager: "osmanage", + Force: true, + }, + ) + } + + if err != nil { + return namespace, fmt.Errorf("applying %s/%s: %w", obj.GetKind(), obj.GetName(), err) + } + + logger.Info("Applied %s: %s", result.GetKind(), result.GetName()) + return namespace, nil +} + +// applyDirectory applies all YAML files in a directory +func applyDirectory(ctx context.Context, k8sClient *client.Client, dirPath string) error { + files, err := os.ReadDir(dirPath) + if err != nil { + return fmt.Errorf("reading directory: %w", err) + } + + for _, file := range files { + if file.IsDir() { + continue + } + + if !isYAMLFile(file.Name()) { + logger.Debug("Skipping non-YAML file: %s", file.Name()) + continue + } + + manifestPath := filepath.Join(dirPath, file.Name()) + if _, err := applyManifest(ctx, k8sClient, manifestPath); err != nil { + logger.Warn("Failed to apply %s: %v", file.Name(), err) + continue + } + } + + return nil +} diff --git a/internal/k8s/actions/cluster_status.go b/internal/k8s/actions/cluster_status.go index 2b1693a..af2e28f 100644 --- a/internal/k8s/actions/cluster_status.go +++ b/internal/k8s/actions/cluster_status.go @@ -14,8 +14,7 @@ import ( const ( ClusterStatusHelp = "Check Kubernetes cluster status" - ClusterStatusHelpExtra = `Checks the status of the Kubernetes cluster by querying node conditions. -Reports the total number of nodes and how many are in Ready state. + ClusterStatusHelpExtra = `Checks the health of all nodes in the Kubernetes cluster. Examples: osmanage k8s cluster-status @@ -63,37 +62,32 @@ func ClusterStatusCmd() *cobra.Command { logger.Info("Ready nodes: %d", status.ReadyNodes) for _, node := range status.Nodes { - statusStr := "NotReady" if node.Ready { - statusStr = "Ready" - } - logger.Info("Node %s: %s", node.Name, statusStr) - - if !node.Ready { - for _, condition := range node.Conditions { - if condition.Status == corev1.ConditionTrue && condition.Type != corev1.NodeReady { - logger.Debug(" - %s: %s (Reason: %s)", condition.Type, condition.Message, condition.Reason) + logger.Info("Node %s: Ready", node.Name) + } else { + logger.Info("Node %s: NotReady", node.Name) + for _, cond := range node.Conditions { + if cond.Status == corev1.ConditionTrue && cond.Type != corev1.NodeReady { + logger.Debug(" - %s: %s (Reason: %s)", cond.Type, cond.Status, cond.Reason) } } } } - if status.ReadyNodes < status.TotalNodes { + if status.ReadyNodes != status.TotalNodes { return fmt.Errorf("cluster is not healthy: %d/%d nodes ready", status.ReadyNodes, status.TotalNodes) } - logger.Info("Cluster is healthy ✓") + logger.Info("Cluster is healthy") return nil } return cmd } -// checkClusterStatus retrieves and analyzes the cluster status +// checkClusterStatus checks the overall cluster health func checkClusterStatus(ctx context.Context, k8sClient *client.Client) (*ClusterStatus, error) { - clientset := k8sClient.Clientset() - - nodes, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) + nodes, err := k8sClient.Clientset().CoreV1().Nodes().List(ctx, metav1.ListOptions{}) if err != nil { return nil, fmt.Errorf("listing nodes: %w", err) } @@ -120,7 +114,7 @@ func checkClusterStatus(ctx context.Context, k8sClient *client.Client) (*Cluster return status, nil } -// isNodeReady checks if a node is in Ready state +// isNodeReady checks if a node is ready func isNodeReady(node *corev1.Node) bool { for _, condition := range node.Status.Conditions { if condition.Type == corev1.NodeReady { @@ -130,32 +124,21 @@ func isNodeReady(node *corev1.Node) bool { return false } -// GetNodeCondition retrieves a specific condition from a node -func GetNodeCondition(node *corev1.Node, conditionType corev1.NodeConditionType) *corev1.NodeCondition { - for i := range node.Status.Conditions { - if node.Status.Conditions[i].Type == conditionType { - return &node.Status.Conditions[i] - } - } - return nil -} - -// IsNodeHealthy checks if a node has any problematic conditions +// IsNodeHealthy checks if a node is healthy (no pressure conditions) func IsNodeHealthy(node *corev1.Node) bool { - readyCondition := GetNodeCondition(node, corev1.NodeReady) - if readyCondition == nil || readyCondition.Status != corev1.ConditionTrue { + if !isNodeReady(node) { return false } - negativeConditions := []corev1.NodeConditionType{ + pressureTypes := []corev1.NodeConditionType{ corev1.NodeMemoryPressure, corev1.NodeDiskPressure, corev1.NodePIDPressure, corev1.NodeNetworkUnavailable, } - for _, condType := range negativeConditions { - condition := GetNodeCondition(node, condType) + for _, pressureType := range pressureTypes { + condition := GetNodeCondition(node, pressureType) if condition != nil && condition.Status == corev1.ConditionTrue { return false } @@ -163,3 +146,13 @@ func IsNodeHealthy(node *corev1.Node) bool { return true } + +// GetNodeCondition retrieves a specific condition from a node +func GetNodeCondition(node *corev1.Node, conditionType corev1.NodeConditionType) *corev1.NodeCondition { + for _, condition := range node.Status.Conditions { + if condition.Type == conditionType { + return &condition + } + } + return nil +} diff --git a/internal/k8s/actions/cluster_status_test.go b/internal/k8s/actions/cluster_status_test.go index 1374ac5..a5cfd3d 100644 --- a/internal/k8s/actions/cluster_status_test.go +++ b/internal/k8s/actions/cluster_status_test.go @@ -1,12 +1,10 @@ +// cluster_status_test.go package actions import ( - "context" "testing" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes/fake" ) func TestIsNodeReady_Ready(t *testing.T) { @@ -55,51 +53,19 @@ func TestIsNodeReady_NoCondition(t *testing.T) { } } -func TestGetNodeCondition_Exists(t *testing.T) { - node := &corev1.Node{ - Status: corev1.NodeStatus{ - Conditions: []corev1.NodeCondition{ - { - Type: corev1.NodeReady, - Status: corev1.ConditionTrue, - }, - { - Type: corev1.NodeMemoryPressure, - Status: corev1.ConditionFalse, - }, - }, - }, - } - - condition := GetNodeCondition(node, corev1.NodeReady) - if condition == nil { - t.Fatal("Expected to find Ready condition") - } - - if condition.Type != corev1.NodeReady { - t.Errorf("Expected condition type %v, got %v", corev1.NodeReady, condition.Type) - } - - if condition.Status != corev1.ConditionTrue { - t.Errorf("Expected condition status %v, got %v", corev1.ConditionTrue, condition.Status) - } -} - -func TestGetNodeCondition_NotExists(t *testing.T) { +func TestIsNodeReady_MultipleConditions(t *testing.T) { node := &corev1.Node{ Status: corev1.NodeStatus{ Conditions: []corev1.NodeCondition{ - { - Type: corev1.NodeReady, - Status: corev1.ConditionTrue, - }, + {Type: corev1.NodeMemoryPressure, Status: corev1.ConditionFalse}, + {Type: corev1.NodeDiskPressure, Status: corev1.ConditionFalse}, + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, }, }, } - condition := GetNodeCondition(node, corev1.NodeDiskPressure) - if condition != nil { - t.Error("Expected condition to be nil when not found") + if !isNodeReady(node) { + t.Error("Expected node to be ready even with multiple conditions") } } @@ -107,22 +73,11 @@ func TestIsNodeHealthy_Healthy(t *testing.T) { node := &corev1.Node{ Status: corev1.NodeStatus{ Conditions: []corev1.NodeCondition{ - { - Type: corev1.NodeReady, - Status: corev1.ConditionTrue, - }, - { - Type: corev1.NodeMemoryPressure, - Status: corev1.ConditionFalse, - }, - { - Type: corev1.NodeDiskPressure, - Status: corev1.ConditionFalse, - }, - { - Type: corev1.NodePIDPressure, - Status: corev1.ConditionFalse, - }, + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + {Type: corev1.NodeMemoryPressure, Status: corev1.ConditionFalse}, + {Type: corev1.NodeDiskPressure, Status: corev1.ConditionFalse}, + {Type: corev1.NodePIDPressure, Status: corev1.ConditionFalse}, + {Type: corev1.NodeNetworkUnavailable, Status: corev1.ConditionFalse}, }, }, } @@ -136,14 +91,8 @@ func TestIsNodeHealthy_NotReady(t *testing.T) { node := &corev1.Node{ Status: corev1.NodeStatus{ Conditions: []corev1.NodeCondition{ - { - Type: corev1.NodeReady, - Status: corev1.ConditionFalse, - }, - { - Type: corev1.NodeMemoryPressure, - Status: corev1.ConditionFalse, - }, + {Type: corev1.NodeReady, Status: corev1.ConditionFalse}, + {Type: corev1.NodeMemoryPressure, Status: corev1.ConditionFalse}, }, }, } @@ -157,20 +106,14 @@ func TestIsNodeHealthy_MemoryPressure(t *testing.T) { node := &corev1.Node{ Status: corev1.NodeStatus{ Conditions: []corev1.NodeCondition{ - { - Type: corev1.NodeReady, - Status: corev1.ConditionTrue, - }, - { - Type: corev1.NodeMemoryPressure, - Status: corev1.ConditionTrue, - }, + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + {Type: corev1.NodeMemoryPressure, Status: corev1.ConditionTrue}, }, }, } if IsNodeHealthy(node) { - t.Error("Expected node to not be healthy when memory pressure is true") + t.Error("Expected node to not be healthy with memory pressure") } } @@ -178,183 +121,133 @@ func TestIsNodeHealthy_DiskPressure(t *testing.T) { node := &corev1.Node{ Status: corev1.NodeStatus{ Conditions: []corev1.NodeCondition{ - { - Type: corev1.NodeReady, - Status: corev1.ConditionTrue, - }, - { - Type: corev1.NodeDiskPressure, - Status: corev1.ConditionTrue, - }, + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + {Type: corev1.NodeDiskPressure, Status: corev1.ConditionTrue}, }, }, } if IsNodeHealthy(node) { - t.Error("Expected node to not be healthy when disk pressure is true") + t.Error("Expected node to not be healthy with disk pressure") } } -func TestCheckClusterStatus_AllNodesReady(t *testing.T) { - nodes := []corev1.Node{ - { - ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, - Status: corev1.NodeStatus{ - Conditions: []corev1.NodeCondition{ - {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, - }, - }, - }, - { - ObjectMeta: metav1.ObjectMeta{Name: "node-2"}, - Status: corev1.NodeStatus{ - Conditions: []corev1.NodeCondition{ - {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, - }, - }, - }, - { - ObjectMeta: metav1.ObjectMeta{Name: "node-3"}, - Status: corev1.NodeStatus{ - Conditions: []corev1.NodeCondition{ - {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, - }, +func TestIsNodeHealthy_PIDPressure(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + {Type: corev1.NodePIDPressure, Status: corev1.ConditionTrue}, }, }, } - fakeClient := fake.NewClientset(&nodes[0], &nodes[1], &nodes[2]) - - ctx := context.Background() - - status, err := checkClusterStatusWithClientset(ctx, fakeClient) - if err != nil { - t.Fatalf("checkClusterStatus failed: %v", err) - } - - if status.TotalNodes != 3 { - t.Errorf("Expected 3 total nodes, got %d", status.TotalNodes) + if IsNodeHealthy(node) { + t.Error("Expected node to not be healthy with PID pressure") } +} - if status.ReadyNodes != 3 { - t.Errorf("Expected 3 ready nodes, got %d", status.ReadyNodes) +func TestIsNodeHealthy_NetworkUnavailable(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + {Type: corev1.NodeNetworkUnavailable, Status: corev1.ConditionTrue}, + }, + }, } - for i, nodeStatus := range status.Nodes { - if !nodeStatus.Ready { - t.Errorf("Expected node %s to be ready", nodes[i].Name) - } + if IsNodeHealthy(node) { + t.Error("Expected node to not be healthy with network unavailable") } } -func TestCheckClusterStatus_SomeNodesNotReady(t *testing.T) { - nodes := []corev1.Node{ - { - ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, - Status: corev1.NodeStatus{ - Conditions: []corev1.NodeCondition{ - {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, - }, - }, - }, - { - ObjectMeta: metav1.ObjectMeta{Name: "node-2"}, - Status: corev1.NodeStatus{ - Conditions: []corev1.NodeCondition{ - {Type: corev1.NodeReady, Status: corev1.ConditionFalse}, +func TestGetNodeCondition_Exists(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + { + Type: corev1.NodeReady, + Status: corev1.ConditionTrue, + Reason: "KubeletReady", }, - }, - }, - { - ObjectMeta: metav1.ObjectMeta{Name: "node-3"}, - Status: corev1.NodeStatus{ - Conditions: []corev1.NodeCondition{ - {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + { + Type: corev1.NodeMemoryPressure, + Status: corev1.ConditionFalse, }, }, }, } - fakeClient := fake.NewClientset(&nodes[0], &nodes[1], &nodes[2]) - - ctx := context.Background() - - status, err := checkClusterStatusWithClientset(ctx, fakeClient) - if err != nil { - t.Fatalf("checkClusterStatus failed: %v", err) - } - - if status.TotalNodes != 3 { - t.Errorf("Expected 3 total nodes, got %d", status.TotalNodes) + condition := GetNodeCondition(node, corev1.NodeReady) + if condition == nil { + t.Fatal("Expected to find Ready condition") } - if status.ReadyNodes != 2 { - t.Errorf("Expected 2 ready nodes, got %d", status.ReadyNodes) + if condition.Type != corev1.NodeReady { + t.Errorf("Expected condition type %v, got %v", corev1.NodeReady, condition.Type) } - expectedReady := map[string]bool{ - "node-1": true, - "node-2": false, - "node-3": true, + if condition.Status != corev1.ConditionTrue { + t.Errorf("Expected condition status %v, got %v", corev1.ConditionTrue, condition.Status) } - for _, nodeStatus := range status.Nodes { - expectedStatus, exists := expectedReady[nodeStatus.Name] - if !exists { - t.Errorf("Unexpected node: %s", nodeStatus.Name) - continue - } - - if nodeStatus.Ready != expectedStatus { - t.Errorf("Node %s: expected ready=%v, got ready=%v", nodeStatus.Name, expectedStatus, nodeStatus.Ready) - } + if condition.Reason != "KubeletReady" { + t.Errorf("Expected reason 'KubeletReady', got %v", condition.Reason) } } -func TestCheckClusterStatus_NoNodes(t *testing.T) { - fakeClient := fake.NewClientset() - - ctx := context.Background() +func TestGetNodeCondition_NotExists(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + }, + }, + } - status, err := checkClusterStatusWithClientset(ctx, fakeClient) - if err != nil { - t.Fatalf("checkClusterStatus failed: %v", err) + condition := GetNodeCondition(node, corev1.NodeMemoryPressure) + if condition != nil { + t.Error("Expected nil when condition doesn't exist") } +} - if status.TotalNodes != 0 { - t.Errorf("Expected 0 total nodes, got %d", status.TotalNodes) +func TestGetNodeCondition_EmptyConditions(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{}, + }, } - if status.ReadyNodes != 0 { - t.Errorf("Expected 0 ready nodes, got %d", status.ReadyNodes) + condition := GetNodeCondition(node, corev1.NodeReady) + if condition != nil { + t.Error("Expected nil when no conditions exist") } } -// Helper function to test checkClusterStatus with a fake clientset -func checkClusterStatusWithClientset(ctx context.Context, clientset *fake.Clientset) (*ClusterStatus, error) { - nodes, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) - if err != nil { - return nil, err +func TestGetNodeCondition_MultipleConditions(t *testing.T) { + node := &corev1.Node{ + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeMemoryPressure, Status: corev1.ConditionFalse}, + {Type: corev1.NodeDiskPressure, Status: corev1.ConditionTrue}, + {Type: corev1.NodePIDPressure, Status: corev1.ConditionFalse}, + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + }, + }, } - status := &ClusterStatus{ - TotalNodes: len(nodes.Items), - Nodes: make([]NodeStatus, 0, len(nodes.Items)), + // Test finding DiskPressure condition + condition := GetNodeCondition(node, corev1.NodeDiskPressure) + if condition == nil { + t.Fatal("Expected to find DiskPressure condition") } - for _, node := range nodes.Items { - nodeStatus := NodeStatus{ - Name: node.Name, - Ready: isNodeReady(&node), - Conditions: node.Status.Conditions, - } - - if nodeStatus.Ready { - status.ReadyNodes++ - } - - status.Nodes = append(status.Nodes, nodeStatus) + if condition.Type != corev1.NodeDiskPressure { + t.Errorf("Expected DiskPressure, got %v", condition.Type) } - return status, nil + if condition.Status != corev1.ConditionTrue { + t.Errorf("Expected condition status True, got %v", condition.Status) + } } diff --git a/internal/k8s/actions/create.go b/internal/k8s/actions/create.go index 78ce773..7e8cdb6 100644 --- a/internal/k8s/actions/create.go +++ b/internal/k8s/actions/create.go @@ -23,8 +23,8 @@ This command: The secrets directory must already exist (created by 'setup' command). Examples: - osmanage k8s create --project-dir ./my-instance --db-password "mydbpass" --superadmin-password "myadminpass" - osmanage k8s create -d ./instance --db-password "$(cat db.txt)" --superadmin-password "$(cat admin.txt)"` + osmanage k8s create ./my-instance --db-password "mydbpass" --superadmin-password "myadminpass" + osmanage k8s create ./my-instance --db-password "$(cat db.txt)" --superadmin-password "$(cat admin.txt)"` adminSecretsFile = "superadmin" pgPasswordFile = "postgres_password" @@ -32,25 +32,24 @@ Examples: func CreateCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "create", + Use: "create ", Short: CreateHelp, Long: CreateHelp + "\n\n" + CreateHelpExtra, - Args: cobra.NoArgs, + Args: cobra.ExactArgs(1), } - projectDir := cmd.Flags().StringP("project-dir", "d", "", "Project directory containing secrets (required)") dbPassword := cmd.Flags().String("db-password", "", "PostgreSQL database password (required)") superadminPassword := cmd.Flags().String("superadmin-password", "", "Superadmin password (required)") - _ = cmd.MarkFlagRequired("project-dir") _ = cmd.MarkFlagRequired("db-password") _ = cmd.MarkFlagRequired("superadmin-password") cmd.RunE = func(cmd *cobra.Command, args []string) error { logger.Info("=== K8S CREATE INSTANCE ===") - logger.Debug("Project directory: %s", *projectDir) + projectDir := args[0] + logger.Debug("Project directory: %s", projectDir) - if err := createInstance(*projectDir, *dbPassword, *superadminPassword); err != nil { + if err := createInstance(projectDir, *dbPassword, *superadminPassword); err != nil { return fmt.Errorf("creating instance: %w", err) } diff --git a/internal/k8s/actions/health.go b/internal/k8s/actions/health.go index 8ac5c2b..a833bed 100644 --- a/internal/k8s/actions/health.go +++ b/internal/k8s/actions/health.go @@ -1,3 +1,4 @@ +// health.go package actions import ( @@ -9,7 +10,6 @@ import ( "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/spf13/cobra" - corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -58,7 +58,7 @@ func HealthCmd() *cobra.Command { return cmd } -// checkHealth checks the current health status +// checkHealth checks the current health status and prints details func checkHealth(ctx context.Context, k8sClient *client.Client, namespace string) error { pods, err := k8sClient.Clientset().CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) if err != nil { @@ -96,68 +96,3 @@ func checkHealth(ctx context.Context, k8sClient *client.Client, namespace string logger.Info("Instance is healthy") return nil } - -// waitForHealthy waits for instance to become healthy -func waitForHealthy(ctx context.Context, k8sClient *client.Client, namespace string, timeout time.Duration) error { - logger.Info("Waiting for instance to become healthy (timeout: %v)", timeout) - - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - timeoutCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - for { - select { - case <-ticker.C: - healthy, ready, total, err := getHealthStatus(ctx, k8sClient, namespace) - if err != nil { - logger.Debug("Error checking health: %v", err) - continue - } - - logger.Debug("Health check: %d/%d pods ready", ready, total) - - if healthy { - logger.Info("Instance is healthy!") - return nil - } - - case <-timeoutCtx.Done(): - return fmt.Errorf("timeout waiting for instance to become healthy") - } - } -} - -// getHealthStatus returns health metrics -func getHealthStatus(ctx context.Context, k8sClient *client.Client, namespace string) (healthy bool, ready, total int, err error) { - pods, err := k8sClient.Clientset().CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) - if err != nil { - return false, 0, 0, err - } - - total = len(pods.Items) - if total == 0 { - return false, 0, 0, nil - } - - ready = 0 - for _, pod := range pods.Items { - if isPodReady(&pod) { - ready++ - } - } - - healthy = ready == total - return healthy, ready, total, nil -} - -// isPodReady checks if a pod is ready -func isPodReady(pod *corev1.Pod) bool { - for _, condition := range pod.Status.Conditions { - if condition.Type == corev1.PodReady { - return condition.Status == corev1.ConditionTrue - } - } - return false -} diff --git a/internal/k8s/actions/health_check.go b/internal/k8s/actions/health_check.go new file mode 100644 index 0000000..8d5337b --- /dev/null +++ b/internal/k8s/actions/health_check.go @@ -0,0 +1,130 @@ +package actions + +import ( + "context" + "fmt" + "time" + + "github.com/OpenSlides/openslides-cli/internal/k8s/client" + "github.com/OpenSlides/openslides-cli/internal/logger" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// waitForHealthy waits for instance to become healthy +func waitForHealthy(ctx context.Context, k8sClient *client.Client, namespace string, timeout time.Duration) error { + logger.Info("Waiting for instance to become healthy (timeout: %v)", timeout) + + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + for { + select { + case <-ticker.C: + healthy, ready, total, err := getHealthStatus(ctx, k8sClient, namespace) + if err != nil { + logger.Debug("Error checking health: %v", err) + continue + } + + logger.Debug("Health check: %d/%d pods ready", ready, total) + + if healthy { + logger.Info("Instance is healthy!") + return nil + } + + case <-timeoutCtx.Done(): + return fmt.Errorf("timeout waiting for instance to become healthy") + } + } +} + +// getHealthStatus returns health metrics +func getHealthStatus(ctx context.Context, k8sClient *client.Client, namespace string) (healthy bool, ready, total int, err error) { + pods, err := k8sClient.Clientset().CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return false, 0, 0, err + } + + total = len(pods.Items) + if total == 0 { + return false, 0, 0, nil + } + + ready = 0 + for _, pod := range pods.Items { + if isPodReady(&pod) { + ready++ + } + } + + healthy = ready == total + return healthy, ready, total, nil +} + +// isPodReady checks if a pod is ready +func isPodReady(pod *corev1.Pod) bool { + for _, condition := range pod.Status.Conditions { + if condition.Type == corev1.PodReady { + return condition.Status == corev1.ConditionTrue + } + } + return false +} + +// namespaceIsActive checks if a namespace exists and is active +func namespaceIsActive(ctx context.Context, k8sClient *client.Client, namespace string) (bool, error) { + ns, err := k8sClient.Clientset().CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("getting namespace: %w", err) + } + + return ns.Status.Phase == corev1.NamespaceActive, nil +} + +// waitForDeploymentReady waits for a specific deployment to be ready +func waitForDeploymentReady(ctx context.Context, k8sClient *client.Client, namespace, deploymentName string, timeout time.Duration) error { + logger.Debug("Waiting for deployment %s to be ready (timeout: %v)", deploymentName, timeout) + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + for { + select { + case <-ticker.C: + deployment, err := k8sClient.Clientset().AppsV1().Deployments(namespace).Get(timeoutCtx, deploymentName, metav1.GetOptions{}) + if err != nil { + logger.Debug("Error getting deployment: %v", err) + continue + } + + // Check if deployment is ready + if deployment.Status.UpdatedReplicas == *deployment.Spec.Replicas && + deployment.Status.AvailableReplicas == *deployment.Spec.Replicas && + deployment.Status.ReadyReplicas == *deployment.Spec.Replicas { + logger.Info("✓ Deployment %s is ready with %d replicas", deploymentName, *deployment.Spec.Replicas) + return nil + } + + logger.Debug("Deployment %s: %d/%d replicas ready", + deploymentName, + deployment.Status.ReadyReplicas, + *deployment.Spec.Replicas) + + case <-timeoutCtx.Done(): + return fmt.Errorf("timeout waiting for deployment %s to become ready", deploymentName) + } + } +} diff --git a/internal/k8s/actions/health_check_test.go b/internal/k8s/actions/health_check_test.go new file mode 100644 index 0000000..235752c --- /dev/null +++ b/internal/k8s/actions/health_check_test.go @@ -0,0 +1,56 @@ +package actions + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" +) + +func TestIsPodReady_Ready(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + {Type: corev1.PodReady, Status: corev1.ConditionTrue}, + }, + }, + } + if !isPodReady(pod) { + t.Error("Expected pod to be ready") + } +} + +func TestIsPodReady_NotReady(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + {Type: corev1.PodReady, Status: corev1.ConditionFalse}, + }, + }, + } + if isPodReady(pod) { + t.Error("Expected pod to not be ready") + } +} + +func TestIsPodReady_NoCondition(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{Conditions: []corev1.PodCondition{}}, + } + if isPodReady(pod) { + t.Error("Expected pod to not be ready when no Ready condition exists") + } +} + +func TestIsPodReady_MultipleConditions(t *testing.T) { + pod := &corev1.Pod{ + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + {Type: corev1.PodScheduled, Status: corev1.ConditionTrue}, + {Type: corev1.PodReady, Status: corev1.ConditionTrue}, + }, + }, + } + if !isPodReady(pod) { + t.Error("Expected pod to be ready even with multiple conditions") + } +} diff --git a/internal/k8s/actions/health_test.go b/internal/k8s/actions/health_test.go deleted file mode 100644 index ea194a8..0000000 --- a/internal/k8s/actions/health_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package actions - -import ( - "testing" - - corev1 "k8s.io/api/core/v1" -) - -func TestIsPodReady(t *testing.T) { - tests := []struct { - name string - pod *corev1.Pod - expected bool - }{ - { - name: "pod is ready", - pod: &corev1.Pod{ - Status: corev1.PodStatus{ - Conditions: []corev1.PodCondition{ - { - Type: corev1.PodReady, - Status: corev1.ConditionTrue, - }, - }, - }, - }, - expected: true, - }, - { - name: "pod is not ready", - pod: &corev1.Pod{ - Status: corev1.PodStatus{ - Conditions: []corev1.PodCondition{ - { - Type: corev1.PodReady, - Status: corev1.ConditionFalse, - }, - }, - }, - }, - expected: false, - }, - { - name: "pod has no ready condition", - pod: &corev1.Pod{ - Status: corev1.PodStatus{ - Conditions: []corev1.PodCondition{}, - }, - }, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isPodReady(tt.pod) - if result != tt.expected { - t.Errorf("isPodReady() = %v, want %v", result, tt.expected) - } - }) - } -} diff --git a/internal/k8s/actions/helpers.go b/internal/k8s/actions/helpers.go new file mode 100644 index 0000000..081a955 --- /dev/null +++ b/internal/k8s/actions/helpers.go @@ -0,0 +1,27 @@ +package actions + +import ( + "os" + "path/filepath" + "strings" +) + +// extractNamespace gets the namespace from project directory path +// Example: "/real/path/to/my.project.dir.url" -> "myprojectdirurl" +func extractNamespace(projectDir string) string { + dirName := filepath.Base(projectDir) + namespace := strings.ReplaceAll(dirName, ".", "") + return namespace +} + +// fileExists checks if a file exists +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// isYAMLFile checks if filename has YAML extension +func isYAMLFile(filename string) bool { + ext := filepath.Ext(filename) + return ext == ".yaml" || ext == ".yml" +} diff --git a/internal/k8s/actions/helpers_test.go b/internal/k8s/actions/helpers_test.go new file mode 100644 index 0000000..406545d --- /dev/null +++ b/internal/k8s/actions/helpers_test.go @@ -0,0 +1,123 @@ +package actions + +import ( + "os" + "testing" +) + +func TestExtractNamespace(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple directory", + input: "my-instance", + expected: "my-instance", + }, + { + name: "directory with dots", + input: "my.instance", + expected: "myinstance", + }, + { + name: "full path with dots", + input: "/home/user/projects/my.instance", + expected: "myinstance", + }, + { + name: "nested path without dots", + input: "/var/lib/openslides/prod-instance", + expected: "prod-instance", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractNamespace(tt.input) + if result != tt.expected { + t.Errorf("extractNamespace(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestFileExists(t *testing.T) { + // Create a temporary file + tmpFile, err := os.CreateTemp("", "test-file-*") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + tmpFile.Close() + + tests := []struct { + name string + path string + expected bool + }{ + { + name: "existing file", + path: tmpFile.Name(), + expected: true, + }, + { + name: "non-existing file", + path: "/tmp/definitely-does-not-exist-12345", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := fileExists(tt.path) + if result != tt.expected { + t.Errorf("fileExists(%q) = %v, want %v", tt.path, result, tt.expected) + } + }) + } +} + +func TestIsYAMLFile(t *testing.T) { + tests := []struct { + name string + filename string + expected bool + }{ + { + name: "yaml extension", + filename: "deployment.yaml", + expected: true, + }, + { + name: "yml extension", + filename: "service.yml", + expected: true, + }, + { + name: "json file", + filename: "config.json", + expected: false, + }, + { + name: "no extension", + filename: "Makefile", + expected: false, + }, + { + name: "yaml in path but not extension", + filename: "/path/yaml/file.txt", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isYAMLFile(tt.filename) + if result != tt.expected { + t.Errorf("isYAMLFile(%q) = %v, want %v", tt.filename, result, tt.expected) + } + }) + } +} diff --git a/internal/k8s/actions/remove.go b/internal/k8s/actions/remove.go index 2296dca..cc9592e 100644 --- a/internal/k8s/actions/remove.go +++ b/internal/k8s/actions/remove.go @@ -16,28 +16,25 @@ WARNING: This operation is irreversible! All configuration files, secrets, and instance data in the directory will be permanently deleted. Examples: - osmanage k8s remove --project-dir ./my-instance - osmanage k8s remove -d ./old-instance` + osmanage k8s remove ./my-instance` ) func RemoveCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "remove", + Use: "remove ", Short: RemoveHelp, Long: RemoveHelp + "\n\n" + RemoveHelpExtra, - Args: cobra.NoArgs, + Args: cobra.ExactArgs(1), } - projectDir := cmd.Flags().StringP("project-dir", "d", "", "Project directory to remove (required)") force := cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") - _ = cmd.MarkFlagRequired("project-dir") - cmd.RunE = func(cmd *cobra.Command, args []string) error { logger.Info("=== K8S REMOVE INSTANCE ===") - logger.Debug("Project directory: %s", *projectDir) + projectDir := args[0] + logger.Debug("Project directory: %s", projectDir) - if err := removeInstance(*projectDir, *force); err != nil { + if err := removeInstance(projectDir, *force); err != nil { return fmt.Errorf("removing instance: %w", err) } diff --git a/internal/k8s/actions/scale.go b/internal/k8s/actions/scale.go new file mode 100644 index 0000000..1aa6d0d --- /dev/null +++ b/internal/k8s/actions/scale.go @@ -0,0 +1,84 @@ +// scale.go +package actions + +import ( + "context" + "fmt" + "path/filepath" + "time" + + "github.com/OpenSlides/openslides-cli/internal/k8s/client" + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/spf13/cobra" +) + +const ( + ScaleHelp = "Scales an OpenSlides service deployment" + ScaleHelpExtra = `Applies the deployment manifest for a specific service after replicas have been modified. + +Note: You must edit the deployment file to change the replica count before running this command. + +Examples: + osmanage k8s scale ./my-instance --service backend + osmanage k8s scale ./my-instance --service autoupdate --skip-ready-check + osmanage k8s scale ./my-instance --service frontend --kubeconfig ~/.kube/config` +) + +func ScaleCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "scale ", + Short: ScaleHelp, + Long: ScaleHelp + "\n\n" + ScaleHelpExtra, + Args: cobra.ExactArgs(1), + } + + service := cmd.Flags().String("service", "", "Service deployment to scale (required)") + kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") + skipReadyCheck := cmd.Flags().Bool("skip-ready-check", false, "Skip waiting for deployment to become ready") + timeout := cmd.Flags().Duration("timeout", 5*time.Minute, "Timeout for ready check") + + _ = cmd.MarkFlagRequired("service") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + projectDir := args[0] + + logger.Info("=== K8S SCALE SERVICE ===") + logger.Debug("Project directory: %s", projectDir) + logger.Info("Service: %s", *service) + + namespace := extractNamespace(projectDir) + logger.Info("Namespace: %s", namespace) + + k8sClient, err := client.New(*kubeconfig) + if err != nil { + return fmt.Errorf("creating k8s client: %w", err) + } + + ctx := context.Background() + + // Construct path to deployment file + deploymentFile := fmt.Sprintf("%s-deployment.yaml", *service) + deploymentPath := filepath.Join(projectDir, "stack", deploymentFile) + + logger.Info("Applying deployment manifest: %s", deploymentPath) + if _, err := applyManifest(ctx, k8sClient, deploymentPath); err != nil { + return fmt.Errorf("applying deployment: %w", err) + } + + if *skipReadyCheck { + logger.Info("Skipping ready check") + return nil + } + + logger.Info("Waiting for deployment to become ready...") + // Wait for the specific deployment (service name is deployment name) + if err := waitForDeploymentReady(ctx, k8sClient, namespace, *service, *timeout); err != nil { + return fmt.Errorf("waiting for deployment ready: %w", err) + } + + logger.Info("✓ Service scaled successfully") + return nil + } + + return cmd +} diff --git a/internal/k8s/actions/start.go b/internal/k8s/actions/start.go index 1077dd1..33ad76f 100644 --- a/internal/k8s/actions/start.go +++ b/internal/k8s/actions/start.go @@ -3,21 +3,12 @@ package actions import ( "context" "fmt" - "os" "path/filepath" "time" "github.com/OpenSlides/openslides-cli/internal/k8s/client" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/spf13/cobra" - - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/util/yaml" - "k8s.io/client-go/discovery" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/restmapper" ) const ( @@ -88,125 +79,9 @@ func StartCmd() *cobra.Command { return fmt.Errorf("waiting for ready: %w", err) } - logger.Info("Instance started successfully") + logger.Info("✓ Instance started successfully") return nil } return cmd } - -// applyManifest applies a single YAML manifest file using RESTMapper -func applyManifest(ctx context.Context, k8sClient *client.Client, manifestPath string) (string, error) { - logger.Debug("Applying manifest: %s", manifestPath) - - data, err := os.ReadFile(manifestPath) - if err != nil { - return "", fmt.Errorf("reading manifest: %w", err) - } - - var obj unstructured.Unstructured - if err := yaml.Unmarshal(data, &obj); err != nil { - return "", fmt.Errorf("parsing YAML: %w", err) - } - - namespace := obj.GetNamespace() - if namespace == "" && obj.GetKind() == "Namespace" { - namespace = obj.GetName() - } - - discoveryClient, err := discovery.NewDiscoveryClientForConfig(k8sClient.Config()) - if err != nil { - return "", fmt.Errorf("creating discovery client: %w", err) - } - - apiGroupResources, err := restmapper.GetAPIGroupResources(discoveryClient) - if err != nil { - return "", fmt.Errorf("getting API group resources: %w", err) - } - - mapper := restmapper.NewDiscoveryRESTMapper(apiGroupResources) - - gvk := obj.GroupVersionKind() - - mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version) - if err != nil { - return "", fmt.Errorf("getting REST mapping for %s: %w", gvk.String(), err) - } - - dynamicClient, err := dynamic.NewForConfig(k8sClient.Config()) - if err != nil { - return "", fmt.Errorf("creating dynamic client: %w", err) - } - - var result *unstructured.Unstructured - if mapping.Scope.Name() == meta.RESTScopeNameNamespace { - if namespace == "" { - namespace = "default" - } - result, err = dynamicClient.Resource(mapping.Resource).Namespace(namespace).Apply( - ctx, - obj.GetName(), - &obj, - metav1.ApplyOptions{ - FieldManager: "osmanage", - Force: true, - }, - ) - } else { - result, err = dynamicClient.Resource(mapping.Resource).Apply( - ctx, - obj.GetName(), - &obj, - metav1.ApplyOptions{ - FieldManager: "osmanage", - Force: true, - }, - ) - } - - if err != nil { - return namespace, fmt.Errorf("applying %s/%s: %w", obj.GetKind(), obj.GetName(), err) - } - - logger.Info("Applied %s: %s", result.GetKind(), result.GetName()) - return namespace, nil -} - -// applyDirectory applies all YAML files in a directory -func applyDirectory(ctx context.Context, k8sClient *client.Client, dirPath string) error { - files, err := os.ReadDir(dirPath) - if err != nil { - return fmt.Errorf("reading directory: %w", err) - } - - for _, file := range files { - if file.IsDir() { - continue - } - - if !isYAMLFile(file.Name()) { - logger.Debug("Skipping non-YAML file: %s", file.Name()) - continue - } - - manifestPath := filepath.Join(dirPath, file.Name()) - if _, err := applyManifest(ctx, k8sClient, manifestPath); err != nil { - logger.Warn("Failed to apply %s: %v", file.Name(), err) - continue - } - } - - return nil -} - -// fileExists checks if a file exists -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -// isYAMLFile checks if filename has YAML extension -func isYAMLFile(filename string) bool { - ext := filepath.Ext(filename) - return ext == ".yaml" || ext == ".yml" -} diff --git a/internal/k8s/actions/start_test.go b/internal/k8s/actions/start_test.go deleted file mode 100644 index 6a13ca8..0000000 --- a/internal/k8s/actions/start_test.go +++ /dev/null @@ -1,294 +0,0 @@ -package actions - -import ( - "os" - "path/filepath" - "testing" - - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/util/yaml" -) - -func TestFileExists(t *testing.T) { - tests := []struct { - name string - path string - expected bool - setup func(t *testing.T) string - cleanup func(path string) - }{ - { - name: "file exists", - expected: true, - setup: func(t *testing.T) string { - tmpFile := filepath.Join(t.TempDir(), "test.yaml") - if err := os.WriteFile(tmpFile, []byte("test"), 0644); err != nil { - t.Fatal(err) - } - return tmpFile - }, - }, - { - name: "file does not exist", - path: "/nonexistent/path/file.yaml", - expected: false, - }, - { - name: "empty path", - path: "", - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - path := tt.path - if tt.setup != nil { - path = tt.setup(t) - } - - result := fileExists(path) - if result != tt.expected { - t.Errorf("fileExists(%q) = %v, want %v", path, result, tt.expected) - } - }) - } -} - -func TestIsYAMLFile(t *testing.T) { - tests := []struct { - name string - filename string - expected bool - }{ - {"yaml extension", "deployment.yaml", true}, - {"yml extension", "service.yml", true}, - {"json file", "config.json", false}, - {"txt file", "readme.txt", false}, - {"no extension", "Dockerfile", false}, - {"multiple dots yaml", "my.config.yaml", true}, - {"multiple dots yml", "my.config.yml", true}, - {"uppercase YAML", "file.YAML", false}, // filepath.Ext is case-sensitive - {"empty string", "", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isYAMLFile(tt.filename) - if result != tt.expected { - t.Errorf("isYAMLFile(%q) = %v, want %v", tt.filename, result, tt.expected) - } - }) - } -} - -func TestApplyDirectory_FileFiltering(t *testing.T) { - // Create temp directory with mixed files - tmpDir := t.TempDir() - - // Create test files - files := map[string]string{ - "deployment.yaml": "apiVersion: apps/v1\nkind: Deployment", - "service.yml": "apiVersion: v1\nkind: Service", - "config.json": `{"key": "value"}`, - "README.md": "# Documentation", - "script.sh": "#!/bin/bash", - ".hidden.yaml": "apiVersion: v1\nkind: Secret", - "nested/deep.yaml": "apiVersion: v1\nkind: ConfigMap", - } - - yamlCount := 0 - for filename, content := range files { - path := filepath.Join(tmpDir, filename) - - // Create subdirectory if needed - dir := filepath.Dir(path) - if dir != tmpDir { - if err := os.MkdirAll(dir, 0755); err != nil { - t.Fatal(err) - } - } - - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - t.Fatal(err) - } - - // Count YAML files - if isYAMLFile(filename) { - yamlCount++ - } - } - - // Read directory and verify filtering logic - entries, err := os.ReadDir(tmpDir) - if err != nil { - t.Fatal(err) - } - - filteredCount := 0 - for _, entry := range entries { - if entry.IsDir() { - continue - } - if isYAMLFile(entry.Name()) { - filteredCount++ - } - } - - // We expect 3 YAML files at top level: deployment.yaml, service.yml, .hidden.yaml - expectedYAMLFiles := 3 - if filteredCount != expectedYAMLFiles { - t.Errorf("Expected %d YAML files, found %d", expectedYAMLFiles, filteredCount) - } -} - -func TestApplyManifest_NamespaceExtraction(t *testing.T) { - tests := []struct { - name string - manifestContent string - expectedNamespace string - expectError bool - }{ - { - name: "namespace resource", - manifestContent: `apiVersion: v1 -kind: Namespace -metadata: - name: test-namespace`, - expectedNamespace: "test-namespace", - expectError: false, - }, - { - name: "namespaced resource with namespace", - manifestContent: `apiVersion: apps/v1 -kind: Deployment -metadata: - name: test-deployment - namespace: my-namespace`, - expectedNamespace: "my-namespace", - expectError: false, - }, - { - name: "namespaced resource without namespace", - manifestContent: `apiVersion: v1 -kind: Service -metadata: - name: test-service`, - expectedNamespace: "", - expectError: false, - }, - { - name: "invalid yaml", - manifestContent: "not: valid: yaml: content:", - expectedNamespace: "", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Parse YAML - var obj unstructured.Unstructured - err := yaml.Unmarshal([]byte(tt.manifestContent), &obj) - - if tt.expectError { - if err == nil { - t.Error("Expected error but got none") - } - return - } - - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - // Extract namespace - namespace := obj.GetNamespace() - if namespace == "" && obj.GetKind() == "Namespace" { - namespace = obj.GetName() - } - - if namespace != tt.expectedNamespace { - t.Errorf("Expected namespace %q, got %q", tt.expectedNamespace, namespace) - } - }) - } -} - -func TestTLSSecretPath(t *testing.T) { - // Verify the constant matches expected path - expected := "secrets/tls-letsencrypt-secret.yaml" - if tlsCertSecretYAML != expected { - t.Errorf("tlsCertSecretYAML = %q, want %q", tlsCertSecretYAML, expected) - } - - // Test path construction - projectDir := "/path/to/project" - fullPath := filepath.Join(projectDir, tlsCertSecretYAML) - expectedPath := "/path/to/project/secrets/tls-letsencrypt-secret.yaml" - - if fullPath != expectedPath { - t.Errorf("Path construction failed: got %q, want %q", fullPath, expectedPath) - } -} - -func TestStartCmd_Flags(t *testing.T) { - cmd := StartCmd() - - // Verify command exists - if cmd == nil { - t.Fatal("StartCmd() returned nil") - } - - // Verify command name - if cmd.Use != "start " { - t.Errorf("Command use = %q, want %q", cmd.Use, "start ") - } - - // Verify flags exist - tests := []struct { - flagName string - defaultValue string - }{ - {"kubeconfig", ""}, - {"skip-ready-check", "false"}, - {"timeout", "5m0s"}, - } - - for _, tt := range tests { - t.Run(tt.flagName, func(t *testing.T) { - flag := cmd.Flags().Lookup(tt.flagName) - if flag == nil { - t.Fatalf("Flag %q not found", tt.flagName) - } - - if flag.DefValue != tt.defaultValue { - t.Errorf("Flag %q default = %q, want %q", tt.flagName, flag.DefValue, tt.defaultValue) - } - }) - } -} - -func TestStartCmd_Args(t *testing.T) { - cmd := StartCmd() - - // Test with no args (should fail) - cmd.SetArgs([]string{}) - err := cmd.Execute() - if err == nil { - t.Error("Expected error with no args, got nil") - } - - // Test with correct number of args (should pass validation, may fail execution) - cmd.SetArgs([]string{"./test-dir"}) - // We don't execute because it would try to connect to k8s - // Just verify args are accepted - if err := cmd.Args(cmd, []string{"./test-dir"}); err != nil { - t.Errorf("Args validation failed with valid args: %v", err) - } - - // Test with too many args (should fail) - if err := cmd.Args(cmd, []string{"./test-dir", "extra"}); err == nil { - t.Error("Expected error with too many args, got nil") - } -} diff --git a/internal/k8s/actions/stop.go b/internal/k8s/actions/stop.go index 94e9527..1625a86 100644 --- a/internal/k8s/actions/stop.go +++ b/internal/k8s/actions/stop.go @@ -17,35 +17,30 @@ import ( const ( StopHelp = "Stop an OpenSlides instance" - StopHelpExtra = `Stops an OpenSlides instance by deleting its namespace. + StopHelpExtra = `Stops an OpenSlides instance by deleting its Kubernetes namespace. If a TLS certificate secret exists, it will be saved before deletion. Examples: - osmanage k8s stop --namespace openslides-prod --project-dir ./my-instance - osmanage k8s stop -n openslides-test --project-dir ./test-instance` + osmanage k8s stop ./my-instance --kubeconfig ~/.kube/config` tlsCertSecret = "tls-letsencrypt" ) func StopCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "stop", + Use: "stop ", Short: StopHelp, Long: StopHelp + "\n\n" + StopHelpExtra, - Args: cobra.NoArgs, + Args: cobra.ExactArgs(1), } - namespace := cmd.Flags().StringP("namespace", "n", "", "Kubernetes namespace to delete (required)") - projectDir := cmd.Flags().StringP("project-dir", "d", "", "Project directory to save TLS secret (required)") kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") - _ = cmd.MarkFlagRequired("namespace") - _ = cmd.MarkFlagRequired("project-dir") - cmd.RunE = func(cmd *cobra.Command, args []string) error { + projectDir := args[0] + logger.Info("=== K8S STOP INSTANCE ===") - logger.Debug("Namespace: %s", *namespace) - logger.Debug("Project directory: %s", *projectDir) + logger.Debug("Project directory: %s", projectDir) k8sClient, err := client.New(*kubeconfig) if err != nil { @@ -54,12 +49,13 @@ func StopCmd() *cobra.Command { ctx := context.Background() - if err := saveTLSSecret(ctx, k8sClient, *namespace, *projectDir); err != nil { + namespace := extractNamespace(projectDir) + if err := saveTLSSecret(ctx, k8sClient, namespace, projectDir); err != nil { logger.Warn("Failed to save TLS secret: %v", err) } - logger.Info("Stopping instance: %s", *namespace) - if err := deleteNamespace(ctx, k8sClient, *namespace); err != nil { + logger.Info("Stopping instance: %s", namespace) + if err := deleteNamespace(ctx, k8sClient, namespace); err != nil { return fmt.Errorf("deleting namespace: %w", err) } diff --git a/internal/k8s/actions/stop_test.go b/internal/k8s/actions/stop_test.go deleted file mode 100644 index 29279fa..0000000 --- a/internal/k8s/actions/stop_test.go +++ /dev/null @@ -1,308 +0,0 @@ -package actions - -import ( - "context" - "fmt" - "os" - "path/filepath" - "testing" - "time" - - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/kubernetes/fake" - k8stesting "k8s.io/client-go/testing" - "sigs.k8s.io/yaml" -) - -func TestSaveTLSSecret_SecretExists(t *testing.T) { - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: tlsCertSecret, - Namespace: "test-namespace", - }, - Type: corev1.SecretTypeTLS, - Data: map[string][]byte{ - "tls.crt": []byte("-----BEGIN CERTIFICATE-----\nfake-cert-data\n-----END CERTIFICATE-----"), - "tls.key": []byte("-----BEGIN PRIVATE KEY-----\nfake-key-data\n-----END PRIVATE KEY-----"), - }, - } - - fakeClient := fake.NewClientset(secret) - - tmpDir, err := os.MkdirTemp("", "stop-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - t.Cleanup(func() { - if err := os.RemoveAll(tmpDir); err != nil { - t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) - } - }) - - ctx := context.Background() - - err = saveTLSSecretWithClientset(ctx, fakeClient, "test-namespace", tmpDir) - if err != nil { - t.Fatalf("saveTLSSecret failed: %v", err) - } - - secretPath := filepath.Join(tmpDir, tlsCertSecretYAML) - if _, err := os.Stat(secretPath); os.IsNotExist(err) { - t.Errorf("Secret file was not created at %s", secretPath) - } - - fileInfo, err := os.Stat(secretPath) - if err != nil { - t.Fatalf("Failed to stat secret file: %v", err) - } - - expectedPerms := os.FileMode(0600) - if fileInfo.Mode().Perm() != expectedPerms { - t.Errorf("Secret file permissions = %v, want %v", fileInfo.Mode().Perm(), expectedPerms) - } - - fileData, err := os.ReadFile(secretPath) - if err != nil { - t.Fatalf("Failed to read secret file: %v", err) - } - - var savedSecret corev1.Secret - if err := yaml.Unmarshal(fileData, &savedSecret); err != nil { - t.Fatalf("Failed to unmarshal saved secret: %v", err) - } - - if string(savedSecret.Data["tls.crt"]) != string(secret.Data["tls.crt"]) { - t.Error("Saved secret tls.crt data doesn't match original") - } - if string(savedSecret.Data["tls.key"]) != string(secret.Data["tls.key"]) { - t.Error("Saved secret tls.key data doesn't match original") - } -} - -func TestSaveTLSSecret_SecretNotExists(t *testing.T) { - fakeClient := fake.NewClientset() - - tmpDir, err := os.MkdirTemp("", "stop-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - t.Cleanup(func() { - if err := os.RemoveAll(tmpDir); err != nil { - t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) - } - }) - - ctx := context.Background() - - err = saveTLSSecretWithClientset(ctx, fakeClient, "test-namespace", tmpDir) - if err != nil { - t.Errorf("saveTLSSecret should not fail when secret doesn't exist, got: %v", err) - } - - secretPath := filepath.Join(tmpDir, tlsCertSecretYAML) - if _, err := os.Stat(secretPath); !os.IsNotExist(err) { - t.Error("Secret file should not be created when secret doesn't exist") - } -} - -func TestSaveTLSSecret_CreatesSecretsDirectory(t *testing.T) { - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: tlsCertSecret, - Namespace: "test-namespace", - }, - Type: corev1.SecretTypeTLS, - Data: map[string][]byte{ - "tls.crt": []byte("cert"), - "tls.key": []byte("key"), - }, - } - - fakeClient := fake.NewClientset(secret) - - tmpDir, err := os.MkdirTemp("", "stop-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - t.Cleanup(func() { - if err := os.RemoveAll(tmpDir); err != nil { - t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) - } - }) - - ctx := context.Background() - - err = saveTLSSecretWithClientset(ctx, fakeClient, "test-namespace", tmpDir) - if err != nil { - t.Fatalf("saveTLSSecret failed: %v", err) - } - - secretsDir := filepath.Join(tmpDir, "secrets") - if _, err := os.Stat(secretsDir); os.IsNotExist(err) { - t.Error("Secrets directory was not created") - } -} - -func TestDeleteNamespace_Success(t *testing.T) { - namespace := &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-namespace", - }, - } - - fakeClient := fake.NewClientset(namespace) - - deleted := false - fakeClient.PrependReactor("delete", "namespaces", func(action k8stesting.Action) (bool, runtime.Object, error) { - deleted = true - return true, nil, nil - }) - - fakeClient.PrependReactor("get", "namespaces", func(action k8stesting.Action) (bool, runtime.Object, error) { - if deleted { - return true, nil, apierrors.NewNotFound(corev1.Resource("namespaces"), "test-namespace") - } - return false, namespace, nil - }) - - ctx := context.Background() - - err := deleteNamespaceWithClientset(ctx, fakeClient, "test-namespace") - if err != nil { - t.Errorf("deleteNamespace failed: %v", err) - } - - if !deleted { - t.Error("Namespace was not deleted") - } -} - -func TestDeleteNamespace_WaitsForDeletion(t *testing.T) { - namespace := &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-namespace", - }, - } - - fakeClient := fake.NewClientset(namespace) - - deleteTime := time.Time{} - deletionStarted := false - - fakeClient.PrependReactor("delete", "namespaces", func(action k8stesting.Action) (bool, runtime.Object, error) { - deleteTime = time.Now() - deletionStarted = true - return true, nil, nil - }) - - checkCount := 0 - fakeClient.PrependReactor("get", "namespaces", func(action k8stesting.Action) (bool, runtime.Object, error) { - checkCount++ - - if !deletionStarted { - return false, namespace, nil - } - - if checkCount <= 3 { - terminatingNs := namespace.DeepCopy() - now := metav1.NewTime(deleteTime) - terminatingNs.DeletionTimestamp = &now - return true, terminatingNs, nil - } - - return true, nil, apierrors.NewNotFound(corev1.Resource("namespaces"), "test-namespace") - }) - - ctx := context.Background() - - start := time.Now() - err := deleteNamespaceWithClientset(ctx, fakeClient, "test-namespace") - duration := time.Since(start) - - if err != nil { - t.Errorf("deleteNamespace failed: %v", err) - } - - if checkCount < 3 { - t.Errorf("Should have checked namespace status multiple times, got %d checks", checkCount) - } - - if duration < 2*time.Second { - t.Errorf("Should have waited for namespace deletion, took only %v", duration) - } -} - -func TestDeleteNamespace_NotFound(t *testing.T) { - fakeClient := fake.NewClientset() - - ctx := context.Background() - - err := deleteNamespaceWithClientset(ctx, fakeClient, "non-existent") - if err != nil { - t.Errorf("deleteNamespace should not fail for non-existent namespace, got: %v", err) - } -} - -// Helper function to test saveTLSSecret with a fake clientset -// This bypasses the client.Client wrapper for testing purposes -func saveTLSSecretWithClientset(ctx context.Context, clientset *fake.Clientset, namespace, projectDir string) error { - secret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, tlsCertSecret, metav1.GetOptions{}) - if err != nil { - return nil - } - - secretsDir := filepath.Join(projectDir, "secrets") - if err := os.MkdirAll(secretsDir, 0755); err != nil { - return err - } - - secretYAML, err := yaml.Marshal(secret) - if err != nil { - return err - } - - secretPath := filepath.Join(projectDir, tlsCertSecretYAML) - if err := os.WriteFile(secretPath, secretYAML, 0600); err != nil { - return err - } - - return nil -} - -// Helper function to test deleteNamespace with a fake clientset -func deleteNamespaceWithClientset(ctx context.Context, clientset *fake.Clientset, namespace string) error { - deletePolicy := metav1.DeletePropagationForeground - deleteOptions := metav1.DeleteOptions{ - PropagationPolicy: &deletePolicy, - } - - err := clientset.CoreV1().Namespaces().Delete(ctx, namespace, deleteOptions) - if err != nil { - if apierrors.IsNotFound(err) { - return nil - } - return err - } - - timeout := time.After(5 * time.Minute) - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - for { - select { - case <-timeout: - return fmt.Errorf("timeout waiting for namespace %s to be deleted", namespace) - case <-ticker.C: - _, err := clientset.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) - if apierrors.IsNotFound(err) { - return nil - } - if err != nil { - return err - } - } - } -} diff --git a/internal/k8s/actions/update_backendmanage.go b/internal/k8s/actions/update_backendmanage.go index 2eb09e4..5e74cf0 100644 --- a/internal/k8s/actions/update_backendmanage.go +++ b/internal/k8s/actions/update_backendmanage.go @@ -4,38 +4,46 @@ import ( "context" "fmt" "strings" + "time" "github.com/OpenSlides/openslides-cli/internal/k8s/client" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" ) const ( - UpdateBackendmanageHelp = "Updates an OpenSlides instance's backendmanage service." + UpdateBackendmanageHelp = "Updates an OpenSlides instance's backend." UpdateBackendmanageHelpExtra = `Updates the backendmanage service deployment image tag and registry to new version. Examples: - osmanage k8s update-backendmanage ./my-instance --kubeconfig ~/.kube/config` - - managementBackend = "backendmanage" + osmanage k8s update-backendmanage --url my.openslides.url.org --kubeconfig ~/.kube/config --tag 4.2.23 --containerRegistry myRegistry` ) func UpdateBackendmanageCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "update-backendmanage ", - Short: StartHelp, - Long: StartHelp + "\n\n" + StartHelpExtra, - Args: cobra.ExactArgs(1), + Use: "update-backendmanage", + Short: UpdateBackendmanageHelp, + Long: UpdateBackendmanageHelp + "\n\n" + UpdateBackendmanageHelpExtra, + Args: cobra.NoArgs, } kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") - tag := cmd.Flags().StringP("tag", "t", "", "OpenSlides backendmanage service image tag") - containerRegistry := cmd.Flags().String("containerRegistry", "", "OpenSlides backendmanage image ContainerRegistry") + revert := cmd.Flags().Bool("revert", false, "Same as update, except not really") + url := cmd.Flags().String("url", "", "The URL string of the OpenSlides instance (required)") + tag := cmd.Flags().StringP("tag", "t", "", "Image tag (required)") + containerRegistry := cmd.Flags().String("containerRegistry", "", "Container registry (required)") + + cmd.MarkFlagRequired("url") + cmd.MarkFlagRequired("tag") + cmd.MarkFlagRequired("containerRegistry") cmd.RunE = func(cmd *cobra.Command, args []string) error { - projectDir := args[0] + namespace := strings.ReplaceAll(*url, ".", "") - logger.Info("=== K8S UPDATE BACKENDMANAGE ===") + logger.Info("=== K8S UPDATE/REVERT BACKENDMANAGE ===") + logger.Info("Namespace: %s", namespace) k8sClient, err := client.New(*kubeconfig) if err != nil { @@ -44,28 +52,106 @@ func UpdateBackendmanageCmd() *cobra.Command { ctx := context.Background() - namespace := strings.ReplaceAll(projectDir, ".", "") - logger.Info("Create namespace string: %s", namespace) + if *revert { + if err := revertBackendmanage(ctx, k8sClient, namespace, *tag, *containerRegistry); err != nil { + return err + } - err := updateBackendmanage(ctx, k8sClient, namespace, tag, containerRegistry) - if err != nil { - return fmt.Errorf("updating backendmanage service: %w", err) + logger.Info("Successfully reverted backendmanage") + } else { + if err := updateBackendmanage(ctx, k8sClient, namespace, *tag, *containerRegistry); err != nil { + return err + } + + logger.Info("Successfully updated backendmanage") } + return nil } return cmd } func updateBackendmanage(ctx context.Context, k8sClient *client.Client, namespace, tag, containerRegistry string) error { - patch := fmt.Sprintf(`{"spec":{"template":{"spec":{"containers":[{"name":"%s","image":"%s"}]}}}}`, container, image) - - _, err := clientset.AppsV1().Deployments(namespace).Patch( - ctx, - deployment, - types.StrategicMergePatchType, - []byte(patch), - metav1.PatchOptions{}, - ) - - return err + image := fmt.Sprintf("%s/openslides-backend:%s", containerRegistry, tag) + + logger.Info("Updating deployment to image: %s", image) + + patch := []byte(fmt.Sprintf(`{"spec":{"template":{"spec":{"containers":[{"name":"backendmanage","image":"%s"}]}}}}`, image)) + + updated, err := k8sClient.Clientset().AppsV1().Deployments(namespace).Patch( + ctx, + "backendmanage", + types.StrategicMergePatchType, + patch, + metav1.PatchOptions{}, + ) + if err != nil { + return fmt.Errorf("patching deployment: %w", err) + } + + logger.Info("Patch applied (generation: %d)", updated.Generation) + + logger.Info("Waiting for rollout to complete...") + if err := waitForRollout(ctx, k8sClient, namespace, "backendmanage", 5*time.Minute); err != nil { + return fmt.Errorf("rollout failed: %w", err) + } + + return nil +} + +func revertBackendmanage(ctx context.Context, k8sClient *client.Client, namespace, tag, containerRegistry string) error { + image := fmt.Sprintf("%s/openslides-backend:%s", containerRegistry, tag) + + logger.Info("Reverting deployment to image: %s", image) + + patch := []byte(fmt.Sprintf(`{"spec":{"template":{"spec":{"containers":[{"name":"backendmanage","image":"%s"}]}}}}`, image)) + + updated, err := k8sClient.Clientset().AppsV1().Deployments(namespace).Patch( + ctx, + "backendmanage", + types.StrategicMergePatchType, + patch, + metav1.PatchOptions{}, + ) + if err != nil { + return fmt.Errorf("patching deployment: %w", err) + } + + logger.Info("Patch applied (generation: %d)", updated.Generation) + + logger.Info("Waiting for rollout to complete...") + if err := waitForRollout(ctx, k8sClient, namespace, "backendmanage", 5*time.Minute); err != nil { + return fmt.Errorf("rollout failed: %w", err) + } + + return nil +} + +func waitForRollout(ctx context.Context, k8sClient *client.Client, namespace, deploymentName string, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timeout after %v", timeout) + + case <-ticker.C: + d, err := k8sClient.Clientset().AppsV1().Deployments(namespace).Get(ctx, deploymentName, metav1.GetOptions{}) + if err != nil { + return err + } + + if d.Status.ObservedGeneration >= d.Generation && + d.Status.UpdatedReplicas == *d.Spec.Replicas && + d.Status.AvailableReplicas == *d.Spec.Replicas { + return nil + } + + logger.Info(" %d/%d replicas ready", d.Status.ReadyReplicas, *d.Spec.Replicas) + } + } } diff --git a/internal/k8s/actions/update_instance.go b/internal/k8s/actions/update_instance.go new file mode 100644 index 0000000..af32296 --- /dev/null +++ b/internal/k8s/actions/update_instance.go @@ -0,0 +1,88 @@ +package actions + +import ( + "context" + "fmt" + "path/filepath" + "time" + + "github.com/OpenSlides/openslides-cli/internal/k8s/client" + "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/spf13/cobra" +) + +const ( + UpdateInstanceHelp = "Updates an OpenSlides instance." + UpdateInstanceHelpExtra = `Updates the instance by applying new manifest files from the project directory. + +Examples: + osmanage k8s update-instance ./my-instance + osmanage k8s update-instance ./my-instance --skip-ready-check + osmanage k8s update-instance ./my-instance --kubeconfig ~/.kube/config` +) + +func UpdateInstanceCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "update-instance ", + Short: UpdateInstanceHelp, + Long: UpdateInstanceHelp + "\n\n" + UpdateInstanceHelpExtra, + Args: cobra.ExactArgs(1), + } + + kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") + skipReadyCheck := cmd.Flags().Bool("skip-ready-check", false, "Skip waiting for instance to become ready") + timeout := cmd.Flags().Duration("timeout", 5*time.Minute, "Timeout for ready check") + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + projectDir := args[0] + + logger.Info("=== K8S UPDATE INSTANCE ===") + logger.Debug("Project directory: %s", projectDir) + + namespace := extractNamespace(projectDir) + logger.Info("Namespace: %s", namespace) + + k8sClient, err := client.New(*kubeconfig) + if err != nil { + return fmt.Errorf("creating k8s client: %w", err) + } + + ctx := context.Background() + + isActive, err := namespaceIsActive(ctx, k8sClient, namespace) + if err != nil { + return fmt.Errorf("checking namespace: %w", err) + } + + if !isActive { + logger.Info("%s is not running.", namespace) + logger.Info("The configuration has been updated and the instance will be upgraded upon its next start.") + logger.Info("Note that the next start might take a long time due to pending migrations.") + logger.Info("Consider starting the instance and running migrations now.") + logger.Info("Alternatively, downgrade for now and run migrations in the background once the instance is started.") + return nil + } + + logger.Info("Updating OpenSlides services.") + + stackDir := filepath.Join(projectDir, "stack") + if err := applyDirectory(ctx, k8sClient, stackDir); err != nil { + return fmt.Errorf("applying stack: %w", err) + } + + if *skipReadyCheck { + logger.Info("Skip ready check.") + return nil + } + + logger.Info("Waiting for instance to become ready...") + if err := waitForHealthy(ctx, k8sClient, namespace, *timeout); err != nil { + return fmt.Errorf("waiting for instance health: %w", err) + } + + logger.Info("✓ Instance updated successfully") + return nil + } + + return cmd +} diff --git a/internal/k8s/client/client.go b/internal/k8s/client/client.go index e2fb701..fc1426a 100644 --- a/internal/k8s/client/client.go +++ b/internal/k8s/client/client.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" + "github.com/OpenSlides/openslides-cli/internal/logger" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" @@ -19,23 +20,40 @@ type Client struct { func New(kubeconfigPath string) (*Client, error) { var config *rest.Config var err error + var source string - config, err = rest.InClusterConfig() - if err != nil { - if kubeconfigPath == "" { - kubeconfigPath = filepath.Join(os.Getenv("HOME"), ".kube", "config") - } + if kubeconfigPath != "" { config, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath) if err != nil { - return nil, fmt.Errorf("failed to create k8s config: %w", err) + return nil, fmt.Errorf("failed to load kubeconfig from %s: %w", kubeconfigPath, err) + } + source = fmt.Sprintf("kubeconfig: %s", kubeconfigPath) + } else { + config, err = rest.InClusterConfig() + if err == nil { + source = "in-cluster service account" + } else { + home := os.Getenv("HOME") + if home == "" { + return nil, fmt.Errorf("failed to get in-cluster config and HOME env var not set") + } + kubeconfigPath = filepath.Join(home, ".kube", "config") + + config, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath) + if err != nil { + return nil, fmt.Errorf("failed to create k8s config: not running in-cluster and no valid kubeconfig found at %s: %w", kubeconfigPath, err) + } + source = fmt.Sprintf("kubeconfig: %s", kubeconfigPath) } } clientset, err := kubernetes.NewForConfig(config) if err != nil { - return nil, fmt.Errorf("failed to create k8s client: %w", err) + return nil, fmt.Errorf("failed to create k8s clientset: %w", err) } + logger.Debug("Kubernetes client initialized from %s", source) + return &Client{ clientset: clientset, config: config, From a2c568d97e0e9c8e4ff2c13dfb79b237a825c551 Mon Sep 17 00:00:00 2001 From: aantoni Date: Fri, 23 Jan 2026 18:01:39 +0100 Subject: [PATCH 04/28] add remove and scale to main, some cleanup/renaming --- cmd/osmanage/main.go | 3 +++ internal/k8s/actions/cluster_status_test.go | 1 - internal/k8s/actions/create.go | 4 ++-- internal/k8s/actions/health.go | 18 ++++++++---------- internal/k8s/actions/helpers_test.go | 16 +++++++++------- internal/k8s/actions/remove.go | 4 ++-- internal/k8s/actions/scale.go | 6 +++--- internal/k8s/actions/start.go | 6 +++--- internal/k8s/actions/stop.go | 2 +- internal/k8s/actions/update_backendmanage.go | 20 +++++++++----------- internal/k8s/actions/update_instance.go | 6 +++--- 11 files changed, 43 insertions(+), 43 deletions(-) diff --git a/cmd/osmanage/main.go b/cmd/osmanage/main.go index 9e1b595..a6205b5 100644 --- a/cmd/osmanage/main.go +++ b/cmd/osmanage/main.go @@ -74,9 +74,12 @@ func RootCmd() *cobra.Command { k8sActions.StartCmd(), k8sActions.StopCmd(), k8sActions.CreateCmd(), + k8sActions.RemoveCmd(), k8sActions.HealthCmd(), k8sActions.ClusterStatusCmd(), k8sActions.UpdateBackendmanageCmd(), + k8sActions.UpdateInstanceCmd(), + k8sActions.ScaleCmd(), ) rootCmd.AddCommand( diff --git a/internal/k8s/actions/cluster_status_test.go b/internal/k8s/actions/cluster_status_test.go index a5cfd3d..44998fc 100644 --- a/internal/k8s/actions/cluster_status_test.go +++ b/internal/k8s/actions/cluster_status_test.go @@ -1,4 +1,3 @@ -// cluster_status_test.go package actions import ( diff --git a/internal/k8s/actions/create.go b/internal/k8s/actions/create.go index 7e8cdb6..9098425 100644 --- a/internal/k8s/actions/create.go +++ b/internal/k8s/actions/create.go @@ -23,8 +23,8 @@ This command: The secrets directory must already exist (created by 'setup' command). Examples: - osmanage k8s create ./my-instance --db-password "mydbpass" --superadmin-password "myadminpass" - osmanage k8s create ./my-instance --db-password "$(cat db.txt)" --superadmin-password "$(cat admin.txt)"` + osmanage k8s create ./my.instance.dir.org --db-password "mydbpass" --superadmin-password "myadminpass" + osmanage k8s create ./my.instance.dir.org --db-password "$(cat db.txt)" --superadmin-password "$(cat admin.txt)"` adminSecretsFile = "superadmin" pgPasswordFile = "postgres_password" diff --git a/internal/k8s/actions/health.go b/internal/k8s/actions/health.go index a833bed..9a49e16 100644 --- a/internal/k8s/actions/health.go +++ b/internal/k8s/actions/health.go @@ -1,4 +1,3 @@ -// health.go package actions import ( @@ -18,8 +17,8 @@ const ( HealthHelpExtra = `Checks if all pods in the instance namespace are ready and running. Examples: - osmanage k8s health --namespace openslides-prod - osmanage k8s health --namespace openslides-test --wait --timeout 5m` + osmanage k8s health ./my.instance.dir.org + osmanage k8s health ./my.instance.dir.org --wait --timeout 5m` ) func HealthCmd() *cobra.Command { @@ -27,19 +26,18 @@ func HealthCmd() *cobra.Command { Use: "health", Short: HealthHelp, Long: HealthHelp + "\n\n" + HealthHelpExtra, - Args: cobra.NoArgs, + Args: cobra.ExactArgs(1), } - namespace := cmd.Flags().StringP("namespace", "n", "", "Kubernetes namespace (required)") kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") wait := cmd.Flags().Bool("wait", false, "Wait for instance to become healthy") timeout := cmd.Flags().Duration("timeout", 5*time.Minute, "Timeout for wait operation") - _ = cmd.MarkFlagRequired("namespace") - cmd.RunE = func(cmd *cobra.Command, args []string) error { logger.Info("=== K8S HEALTH CHECK ===") - logger.Debug("Namespace: %s", *namespace) + projectDir := args[0] + namespace := extractNamespace(projectDir) + logger.Debug("Namespace: %s", namespace) k8sClient, err := client.New(*kubeconfig) if err != nil { @@ -49,10 +47,10 @@ func HealthCmd() *cobra.Command { ctx := context.Background() if *wait { - return waitForHealthy(ctx, k8sClient, *namespace, *timeout) + return waitForHealthy(ctx, k8sClient, namespace, *timeout) } - return checkHealth(ctx, k8sClient, *namespace) + return checkHealth(ctx, k8sClient, namespace) } return cmd diff --git a/internal/k8s/actions/helpers_test.go b/internal/k8s/actions/helpers_test.go index 406545d..195b4da 100644 --- a/internal/k8s/actions/helpers_test.go +++ b/internal/k8s/actions/helpers_test.go @@ -18,17 +18,17 @@ func TestExtractNamespace(t *testing.T) { }, { name: "directory with dots", - input: "my.instance", - expected: "myinstance", + input: "my.instance.org", + expected: "myinstanceorg", }, { name: "full path with dots", - input: "/home/user/projects/my.instance", - expected: "myinstance", + input: "/home/user/projects/my.instance.org", + expected: "myinstanceorg", }, { name: "nested path without dots", - input: "/var/lib/openslides/prod-instance", + input: "/var/lib/test/prod-instance", expected: "prod-instance", }, } @@ -49,8 +49,10 @@ func TestFileExists(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp file: %v", err) } - defer os.Remove(tmpFile.Name()) - tmpFile.Close() + defer func() { + _ = tmpFile.Close() + _ = os.Remove(tmpFile.Name()) + }() tests := []struct { name string diff --git a/internal/k8s/actions/remove.go b/internal/k8s/actions/remove.go index cc9592e..35a1c42 100644 --- a/internal/k8s/actions/remove.go +++ b/internal/k8s/actions/remove.go @@ -16,7 +16,7 @@ WARNING: This operation is irreversible! All configuration files, secrets, and instance data in the directory will be permanently deleted. Examples: - osmanage k8s remove ./my-instance` + osmanage k8s remove ./my.instance.dir.org` ) func RemoveCmd() *cobra.Command { @@ -65,7 +65,7 @@ func removeInstance(projectDir string, force bool) error { fmt.Print("Are you sure you want to continue? [y/N]: ") var response string - fmt.Scanln(&response) + _, _ = fmt.Scanln(&response) if response != "y" && response != "Y" && response != "yes" && response != "YES" { logger.Info("Removal cancelled") diff --git a/internal/k8s/actions/scale.go b/internal/k8s/actions/scale.go index 1aa6d0d..e99d073 100644 --- a/internal/k8s/actions/scale.go +++ b/internal/k8s/actions/scale.go @@ -19,9 +19,9 @@ const ( Note: You must edit the deployment file to change the replica count before running this command. Examples: - osmanage k8s scale ./my-instance --service backend - osmanage k8s scale ./my-instance --service autoupdate --skip-ready-check - osmanage k8s scale ./my-instance --service frontend --kubeconfig ~/.kube/config` + osmanage k8s scale ./my.instance.dir.org --service backendmanage + osmanage k8s scale ./my.instance.dir.org --service autoupdate --skip-ready-check + osmanage k8s scale ./my.instance.dir.org --service search --kubeconfig ~/.kube/config` ) func ScaleCmd() *cobra.Command { diff --git a/internal/k8s/actions/start.go b/internal/k8s/actions/start.go index 33ad76f..1218f68 100644 --- a/internal/k8s/actions/start.go +++ b/internal/k8s/actions/start.go @@ -16,9 +16,9 @@ const ( StartHelpExtra = `Applies Kubernetes manifests to start an OpenSlides instance. Examples: - osmanage k8s start ./my-instance - osmanage k8s start ./my-instance --skip-ready-check - osmanage k8s start ./my-instance --kubeconfig ~/.kube/config` + osmanage k8s start ./my.instance.dir.org + osmanage k8s start ./my.instance.dir.org --skip-ready-check + osmanage k8s start ./my.instance.dir.org --kubeconfig ~/.kube/config` tlsCertSecretYAML = "secrets/tls-letsencrypt-secret.yaml" ) diff --git a/internal/k8s/actions/stop.go b/internal/k8s/actions/stop.go index 1625a86..5d40dd7 100644 --- a/internal/k8s/actions/stop.go +++ b/internal/k8s/actions/stop.go @@ -21,7 +21,7 @@ const ( If a TLS certificate secret exists, it will be saved before deletion. Examples: - osmanage k8s stop ./my-instance --kubeconfig ~/.kube/config` + osmanage k8s stop ./my.instance.dir.org --kubeconfig ~/.kube/config` tlsCertSecret = "tls-letsencrypt" ) diff --git a/internal/k8s/actions/update_backendmanage.go b/internal/k8s/actions/update_backendmanage.go index 5e74cf0..3a9c9ce 100644 --- a/internal/k8s/actions/update_backendmanage.go +++ b/internal/k8s/actions/update_backendmanage.go @@ -3,7 +3,6 @@ package actions import ( "context" "fmt" - "strings" "time" "github.com/OpenSlides/openslides-cli/internal/k8s/client" @@ -18,31 +17,30 @@ const ( UpdateBackendmanageHelpExtra = `Updates the backendmanage service deployment image tag and registry to new version. Examples: - osmanage k8s update-backendmanage --url my.openslides.url.org --kubeconfig ~/.kube/config --tag 4.2.23 --containerRegistry myRegistry` + osmanage k8s update-backendmanage ./my.instance.dir.org --kubeconfig ~/.kube/config --tag 4.2.23 --containerRegistry myRegistry` ) func UpdateBackendmanageCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "update-backendmanage", + Use: "update-backendmanage ", Short: UpdateBackendmanageHelp, Long: UpdateBackendmanageHelp + "\n\n" + UpdateBackendmanageHelpExtra, - Args: cobra.NoArgs, + Args: cobra.ExactArgs(1), } kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") - revert := cmd.Flags().Bool("revert", false, "Same as update, except not really") - url := cmd.Flags().String("url", "", "The URL string of the OpenSlides instance (required)") + revert := cmd.Flags().Bool("revert", false, "Changes image back with given tag and registry") tag := cmd.Flags().StringP("tag", "t", "", "Image tag (required)") containerRegistry := cmd.Flags().String("containerRegistry", "", "Container registry (required)") - cmd.MarkFlagRequired("url") - cmd.MarkFlagRequired("tag") - cmd.MarkFlagRequired("containerRegistry") + _ = cmd.MarkFlagRequired("tag") + _ = cmd.MarkFlagRequired("containerRegistry") cmd.RunE = func(cmd *cobra.Command, args []string) error { - namespace := strings.ReplaceAll(*url, ".", "") - logger.Info("=== K8S UPDATE/REVERT BACKENDMANAGE ===") + projectDir := args[0] + namespace := extractNamespace(projectDir) + logger.Info("Namespace: %s", namespace) k8sClient, err := client.New(*kubeconfig) diff --git a/internal/k8s/actions/update_instance.go b/internal/k8s/actions/update_instance.go index af32296..640f0d9 100644 --- a/internal/k8s/actions/update_instance.go +++ b/internal/k8s/actions/update_instance.go @@ -16,9 +16,9 @@ const ( UpdateInstanceHelpExtra = `Updates the instance by applying new manifest files from the project directory. Examples: - osmanage k8s update-instance ./my-instance - osmanage k8s update-instance ./my-instance --skip-ready-check - osmanage k8s update-instance ./my-instance --kubeconfig ~/.kube/config` + osmanage k8s update-instance ./my.instance.dir.org + osmanage k8s update-instance ./my.instance.dir.org --skip-ready-check + osmanage k8s update-instance ./my.instance.dir.org --kubeconfig ~/.kube/config` ) func UpdateInstanceCmd() *cobra.Command { From b75fddcc06a215fc85511bfc0c116a3cfce797a1 Mon Sep 17 00:00:00 2001 From: aantoni Date: Tue, 27 Jan 2026 16:59:28 +0100 Subject: [PATCH 05/28] flag improvements, remove redundancy, make cli more robust --- internal/actions/setpassword/setpassword.go | 4 + internal/k8s/actions/cluster_status.go | 2 + internal/k8s/actions/create.go | 34 ++---- internal/k8s/actions/create_test.go | 112 ------------------- internal/k8s/actions/health.go | 4 +- internal/k8s/actions/health_check.go | 13 ++- internal/k8s/actions/remove.go | 2 +- internal/k8s/actions/scale.go | 12 +- internal/k8s/actions/start.go | 4 +- internal/k8s/actions/update_backendmanage.go | 70 +++++------- internal/k8s/actions/update_instance.go | 2 +- 11 files changed, 63 insertions(+), 196 deletions(-) diff --git a/internal/actions/setpassword/setpassword.go b/internal/actions/setpassword/setpassword.go index 0d7a76d..c6eb656 100644 --- a/internal/actions/setpassword/setpassword.go +++ b/internal/actions/setpassword/setpassword.go @@ -33,6 +33,10 @@ func Cmd() *cobra.Command { _ = cmd.MarkFlagRequired("password") cmd.RunE = func(cmd *cobra.Command, args []string) error { + if *password == "" { + return fmt.Errorf("--password cannot be empty") + } + logger.Info("=== SET PASSWORD ===") logger.Debug("Setting password for user ID: %d", *userID) diff --git a/internal/k8s/actions/cluster_status.go b/internal/k8s/actions/cluster_status.go index af2e28f..7229125 100644 --- a/internal/k8s/actions/cluster_status.go +++ b/internal/k8s/actions/cluster_status.go @@ -58,6 +58,8 @@ func ClusterStatusCmd() *cobra.Command { return fmt.Errorf("checking cluster status: %w", err) } + fmt.Printf("cluster_status: %d %d\n", status.TotalNodes, status.ReadyNodes) + logger.Info("Total nodes: %d", status.TotalNodes) logger.Info("Ready nodes: %d", status.ReadyNodes) diff --git a/internal/k8s/actions/create.go b/internal/k8s/actions/create.go index 9098425..ed50307 100644 --- a/internal/k8s/actions/create.go +++ b/internal/k8s/actions/create.go @@ -45,6 +45,13 @@ func CreateCmd() *cobra.Command { _ = cmd.MarkFlagRequired("superadmin-password") cmd.RunE = func(cmd *cobra.Command, args []string) error { + if *dbPassword == "" { + return fmt.Errorf("--db-password cannot be empty") + } + if *superadminPassword == "" { + return fmt.Errorf("--superadmin-password cannot be empty") + } + logger.Info("=== K8S CREATE INSTANCE ===") projectDir := args[0] logger.Debug("Project directory: %s", projectDir) @@ -77,13 +84,13 @@ func createInstance(projectDir, dbPassword, superadminPassword string) error { pgPasswordPath := filepath.Join(secretsDir, pgPasswordFile) logger.Debug("Writing PostgreSQL password to: %s", pgPasswordPath) - if err := writeSecretFile(pgPasswordPath, dbPassword); err != nil { + if err := os.WriteFile(pgPasswordPath, []byte(dbPassword), 0600); err != nil { return fmt.Errorf("writing postgres password: %w", err) } superadminPath := filepath.Join(secretsDir, adminSecretsFile) logger.Debug("Writing superadmin password to: %s", superadminPath) - if err := writeSecretFile(superadminPath, superadminPassword); err != nil { + if err := os.WriteFile(superadminPath, []byte(superadminPassword), 0600); err != nil { return fmt.Errorf("writing superadmin password: %w", err) } @@ -115,26 +122,3 @@ func secureSecretsDirectory(secretsDir string) error { return nil } - -// writeSecretFile writes a secret to a file with secure permissions -func writeSecretFile(path, secret string) error { - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return fmt.Errorf("opening file: %w", err) - } - defer func() { - if closeErr := file.Close(); closeErr != nil && err == nil { - err = fmt.Errorf("closing file %s: %w", file.Name(), closeErr) - } - }() - - if err := file.Chmod(0600); err != nil { - return fmt.Errorf("setting file permissions: %w", err) - } - - if _, err := file.WriteString(secret); err != nil { - return fmt.Errorf("writing secret: %w", err) - } - - return nil -} diff --git a/internal/k8s/actions/create_test.go b/internal/k8s/actions/create_test.go index 62a5ad9..76d679d 100644 --- a/internal/k8s/actions/create_test.go +++ b/internal/k8s/actions/create_test.go @@ -6,118 +6,6 @@ import ( "testing" ) -func TestWriteSecretFile(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "create-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - t.Cleanup(func() { - if err := os.RemoveAll(tmpDir); err != nil { - t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) - } - }) - - secretPath := filepath.Join(tmpDir, "test_secret") - secretValue := "my-secret-password" - - // Write secret - err = writeSecretFile(secretPath, secretValue) - if err != nil { - t.Fatalf("writeSecretFile failed: %v", err) - } - - // Verify file exists - if _, err := os.Stat(secretPath); os.IsNotExist(err) { - t.Errorf("Secret file was not created at %s", secretPath) - } - - // Verify file permissions are 0600 - fileInfo, err := os.Stat(secretPath) - if err != nil { - t.Fatalf("Failed to stat secret file: %v", err) - } - - expectedPerms := os.FileMode(0600) - if fileInfo.Mode().Perm() != expectedPerms { - t.Errorf("Secret file permissions = %v, want %v", fileInfo.Mode().Perm(), expectedPerms) - } - - // Verify content - content, err := os.ReadFile(secretPath) - if err != nil { - t.Fatalf("Failed to read secret file: %v", err) - } - - if string(content) != secretValue { - t.Errorf("Secret content = %q, want %q", string(content), secretValue) - } -} - -func TestWriteSecretFile_NoNewline(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "create-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - t.Cleanup(func() { - if err := os.RemoveAll(tmpDir); err != nil { - t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) - } - }) - - secretPath := filepath.Join(tmpDir, "test_secret") - secretValue := "password123" - - err = writeSecretFile(secretPath, secretValue) - if err != nil { - t.Fatalf("writeSecretFile failed: %v", err) - } - - content, err := os.ReadFile(secretPath) - if err != nil { - t.Fatalf("Failed to read secret file: %v", err) - } - - // Verify no trailing newline (matching shell's printf behavior) - if string(content) != secretValue { - t.Errorf("Secret should not have trailing newline, got %q", string(content)) - } -} - -func TestWriteSecretFile_Overwrite(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "create-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - t.Cleanup(func() { - if err := os.RemoveAll(tmpDir); err != nil { - t.Errorf("Failed to remove temp dir %s: %v", tmpDir, err) - } - }) - - secretPath := filepath.Join(tmpDir, "test_secret") - - // Write initial secret - if err := writeSecretFile(secretPath, "old-secret"); err != nil { - t.Fatalf("First writeSecretFile failed: %v", err) - } - - // Overwrite with new secret - newSecret := "new-secret" - if err := writeSecretFile(secretPath, newSecret); err != nil { - t.Fatalf("Second writeSecretFile failed: %v", err) - } - - // Verify new content - content, err := os.ReadFile(secretPath) - if err != nil { - t.Fatalf("Failed to read secret file: %v", err) - } - - if string(content) != newSecret { - t.Errorf("Secret content = %q, want %q", string(content), newSecret) - } -} - func TestSecureSecretsDirectory(t *testing.T) { tmpDir, err := os.MkdirTemp("", "create-test-*") if err != nil { diff --git a/internal/k8s/actions/health.go b/internal/k8s/actions/health.go index 9a49e16..004acd2 100644 --- a/internal/k8s/actions/health.go +++ b/internal/k8s/actions/health.go @@ -18,7 +18,7 @@ const ( Examples: osmanage k8s health ./my.instance.dir.org - osmanage k8s health ./my.instance.dir.org --wait --timeout 5m` + osmanage k8s health ./my.instance.dir.org --wait --timeout 30s` ) func HealthCmd() *cobra.Command { @@ -31,7 +31,7 @@ func HealthCmd() *cobra.Command { kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") wait := cmd.Flags().Bool("wait", false, "Wait for instance to become healthy") - timeout := cmd.Flags().Duration("timeout", 5*time.Minute, "Timeout for wait operation") + timeout := cmd.Flags().Duration("timeout", 3*time.Minute, "Timeout for wait operation") cmd.RunE = func(cmd *cobra.Command, args []string) error { logger.Info("=== K8S HEALTH CHECK ===") diff --git a/internal/k8s/actions/health_check.go b/internal/k8s/actions/health_check.go index 8d5337b..64ff76f 100644 --- a/internal/k8s/actions/health_check.go +++ b/internal/k8s/actions/health_check.go @@ -110,18 +110,21 @@ func waitForDeploymentReady(ctx context.Context, k8sClient *client.Client, names continue } - // Check if deployment is ready - if deployment.Status.UpdatedReplicas == *deployment.Spec.Replicas && + if deployment.Status.ObservedGeneration >= deployment.Generation && + deployment.Status.UpdatedReplicas == *deployment.Spec.Replicas && deployment.Status.AvailableReplicas == *deployment.Spec.Replicas && deployment.Status.ReadyReplicas == *deployment.Spec.Replicas { - logger.Info("✓ Deployment %s is ready with %d replicas", deploymentName, *deployment.Spec.Replicas) + + logger.Info("Deployment %s is ready with %d replicas", deploymentName, *deployment.Spec.Replicas) return nil } - logger.Debug("Deployment %s: %d/%d replicas ready", + logger.Debug("Deployment %s: %d/%d replicas ready (generation: %d/%d)", deploymentName, deployment.Status.ReadyReplicas, - *deployment.Spec.Replicas) + *deployment.Spec.Replicas, + deployment.Status.ObservedGeneration, + deployment.Generation) case <-timeoutCtx.Done(): return fmt.Errorf("timeout waiting for deployment %s to become ready", deploymentName) diff --git a/internal/k8s/actions/remove.go b/internal/k8s/actions/remove.go index 35a1c42..104296a 100644 --- a/internal/k8s/actions/remove.go +++ b/internal/k8s/actions/remove.go @@ -16,7 +16,7 @@ WARNING: This operation is irreversible! All configuration files, secrets, and instance data in the directory will be permanently deleted. Examples: - osmanage k8s remove ./my.instance.dir.org` + osmanage k8s remove ./my.instance.dir.org --force` ) func RemoveCmd() *cobra.Command { diff --git a/internal/k8s/actions/scale.go b/internal/k8s/actions/scale.go index e99d073..8fd1ea9 100644 --- a/internal/k8s/actions/scale.go +++ b/internal/k8s/actions/scale.go @@ -1,4 +1,3 @@ -// scale.go package actions import ( @@ -21,7 +20,7 @@ Note: You must edit the deployment file to change the replica count before runni Examples: osmanage k8s scale ./my.instance.dir.org --service backendmanage osmanage k8s scale ./my.instance.dir.org --service autoupdate --skip-ready-check - osmanage k8s scale ./my.instance.dir.org --service search --kubeconfig ~/.kube/config` + osmanage k8s scale ./my.instance.dir.org --service search --kubeconfig ~/.kube/config --timeout 30s` ) func ScaleCmd() *cobra.Command { @@ -35,14 +34,17 @@ func ScaleCmd() *cobra.Command { service := cmd.Flags().String("service", "", "Service deployment to scale (required)") kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") skipReadyCheck := cmd.Flags().Bool("skip-ready-check", false, "Skip waiting for deployment to become ready") - timeout := cmd.Flags().Duration("timeout", 5*time.Minute, "Timeout for ready check") + timeout := cmd.Flags().Duration("timeout", 3*time.Minute, "Timeout for ready check") _ = cmd.MarkFlagRequired("service") cmd.RunE = func(cmd *cobra.Command, args []string) error { - projectDir := args[0] + if *service == "" { + return fmt.Errorf("--service cannot be empty") + } logger.Info("=== K8S SCALE SERVICE ===") + projectDir := args[0] logger.Debug("Project directory: %s", projectDir) logger.Info("Service: %s", *service) @@ -76,7 +78,7 @@ func ScaleCmd() *cobra.Command { return fmt.Errorf("waiting for deployment ready: %w", err) } - logger.Info("✓ Service scaled successfully") + logger.Info("Service scaled successfully") return nil } diff --git a/internal/k8s/actions/start.go b/internal/k8s/actions/start.go index 1218f68..bb2b78e 100644 --- a/internal/k8s/actions/start.go +++ b/internal/k8s/actions/start.go @@ -18,7 +18,7 @@ const ( Examples: osmanage k8s start ./my.instance.dir.org osmanage k8s start ./my.instance.dir.org --skip-ready-check - osmanage k8s start ./my.instance.dir.org --kubeconfig ~/.kube/config` + osmanage k8s start ./my.instance.dir.org --kubeconfig ~/.kube/config --timeout 30s` tlsCertSecretYAML = "secrets/tls-letsencrypt-secret.yaml" ) @@ -33,7 +33,7 @@ func StartCmd() *cobra.Command { kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") skipReadyCheck := cmd.Flags().Bool("skip-ready-check", false, "Skip waiting for instance to become ready") - timeout := cmd.Flags().Duration("timeout", 5*time.Minute, "Timeout for ready check") + timeout := cmd.Flags().Duration("timeout", 3*time.Minute, "Timeout for ready check") cmd.RunE = func(cmd *cobra.Command, args []string) error { projectDir := args[0] diff --git a/internal/k8s/actions/update_backendmanage.go b/internal/k8s/actions/update_backendmanage.go index 3a9c9ce..cd39b55 100644 --- a/internal/k8s/actions/update_backendmanage.go +++ b/internal/k8s/actions/update_backendmanage.go @@ -17,7 +17,12 @@ const ( UpdateBackendmanageHelpExtra = `Updates the backendmanage service deployment image tag and registry to new version. Examples: - osmanage k8s update-backendmanage ./my.instance.dir.org --kubeconfig ~/.kube/config --tag 4.2.23 --containerRegistry myRegistry` + osmanage k8s update-backendmanage ./my.instance.dir.org --kubeconfig ~/.kube/config --tag 4.2.23 --container-registry myRegistry + osmanage k8s update-backendmanage ./my.instance.dir.org --tag 4.2.23 --container-registry myRegistry --timeout 30s + osmanage k8s update-backendmanage ./my.instance.dir.org --tag 4.2.23 --container-registry myRegistry --revert --timeout 30s` + + backendmanageDeployment = "backendmanage" + backendmanageContainer = "backendmanage" ) func UpdateBackendmanageCmd() *cobra.Command { @@ -28,15 +33,23 @@ func UpdateBackendmanageCmd() *cobra.Command { Args: cobra.ExactArgs(1), } + tag := cmd.Flags().StringP("tag", "t", "", "Image tag (required)") + containerRegistry := cmd.Flags().String("container-registry", "", "Container registry (required)") kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") revert := cmd.Flags().Bool("revert", false, "Changes image back with given tag and registry") - tag := cmd.Flags().StringP("tag", "t", "", "Image tag (required)") - containerRegistry := cmd.Flags().String("containerRegistry", "", "Container registry (required)") + timeout := cmd.Flags().Duration("timeout", 3*time.Minute, "Timeout for deployment readiness check") _ = cmd.MarkFlagRequired("tag") - _ = cmd.MarkFlagRequired("containerRegistry") + _ = cmd.MarkFlagRequired("container-registry") cmd.RunE = func(cmd *cobra.Command, args []string) error { + if *tag == "" { + return fmt.Errorf("--tag cannot be empty") + } + if *containerRegistry == "" { + return fmt.Errorf("--container-registry cannot be empty") + } + logger.Info("=== K8S UPDATE/REVERT BACKENDMANAGE ===") projectDir := args[0] namespace := extractNamespace(projectDir) @@ -51,13 +64,13 @@ func UpdateBackendmanageCmd() *cobra.Command { ctx := context.Background() if *revert { - if err := revertBackendmanage(ctx, k8sClient, namespace, *tag, *containerRegistry); err != nil { + if err := revertBackendmanage(ctx, k8sClient, namespace, *tag, *containerRegistry, *timeout); err != nil { return err } logger.Info("Successfully reverted backendmanage") } else { - if err := updateBackendmanage(ctx, k8sClient, namespace, *tag, *containerRegistry); err != nil { + if err := updateBackendmanage(ctx, k8sClient, namespace, *tag, *containerRegistry, *timeout); err != nil { return err } @@ -69,16 +82,16 @@ func UpdateBackendmanageCmd() *cobra.Command { return cmd } -func updateBackendmanage(ctx context.Context, k8sClient *client.Client, namespace, tag, containerRegistry string) error { +func updateBackendmanage(ctx context.Context, k8sClient *client.Client, namespace, tag, containerRegistry string, timeout time.Duration) error { image := fmt.Sprintf("%s/openslides-backend:%s", containerRegistry, tag) logger.Info("Updating deployment to image: %s", image) - patch := []byte(fmt.Sprintf(`{"spec":{"template":{"spec":{"containers":[{"name":"backendmanage","image":"%s"}]}}}}`, image)) + patch := []byte(fmt.Sprintf(`{"spec":{"template":{"spec":{"containers":[{"name":"%s","image":"%s"}]}}}}`, backendmanageContainer, image)) updated, err := k8sClient.Clientset().AppsV1().Deployments(namespace).Patch( ctx, - "backendmanage", + backendmanageDeployment, types.StrategicMergePatchType, patch, metav1.PatchOptions{}, @@ -90,23 +103,23 @@ func updateBackendmanage(ctx context.Context, k8sClient *client.Client, namespac logger.Info("Patch applied (generation: %d)", updated.Generation) logger.Info("Waiting for rollout to complete...") - if err := waitForRollout(ctx, k8sClient, namespace, "backendmanage", 5*time.Minute); err != nil { + if err := waitForDeploymentReady(ctx, k8sClient, namespace, backendmanageDeployment, timeout); err != nil { return fmt.Errorf("rollout failed: %w", err) } return nil } -func revertBackendmanage(ctx context.Context, k8sClient *client.Client, namespace, tag, containerRegistry string) error { +func revertBackendmanage(ctx context.Context, k8sClient *client.Client, namespace, tag, containerRegistry string, timeout time.Duration) error { image := fmt.Sprintf("%s/openslides-backend:%s", containerRegistry, tag) logger.Info("Reverting deployment to image: %s", image) - patch := []byte(fmt.Sprintf(`{"spec":{"template":{"spec":{"containers":[{"name":"backendmanage","image":"%s"}]}}}}`, image)) + patch := []byte(fmt.Sprintf(`{"spec":{"template":{"spec":{"containers":[{"name":"%s","image":"%s"}]}}}}`, backendmanageContainer, image)) updated, err := k8sClient.Clientset().AppsV1().Deployments(namespace).Patch( ctx, - "backendmanage", + backendmanageDeployment, types.StrategicMergePatchType, patch, metav1.PatchOptions{}, @@ -118,38 +131,9 @@ func revertBackendmanage(ctx context.Context, k8sClient *client.Client, namespac logger.Info("Patch applied (generation: %d)", updated.Generation) logger.Info("Waiting for rollout to complete...") - if err := waitForRollout(ctx, k8sClient, namespace, "backendmanage", 5*time.Minute); err != nil { + if err := waitForDeploymentReady(ctx, k8sClient, namespace, backendmanageDeployment, timeout); err != nil { return fmt.Errorf("rollout failed: %w", err) } return nil } - -func waitForRollout(ctx context.Context, k8sClient *client.Client, namespace, deploymentName string, timeout time.Duration) error { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return fmt.Errorf("timeout after %v", timeout) - - case <-ticker.C: - d, err := k8sClient.Clientset().AppsV1().Deployments(namespace).Get(ctx, deploymentName, metav1.GetOptions{}) - if err != nil { - return err - } - - if d.Status.ObservedGeneration >= d.Generation && - d.Status.UpdatedReplicas == *d.Spec.Replicas && - d.Status.AvailableReplicas == *d.Spec.Replicas { - return nil - } - - logger.Info(" %d/%d replicas ready", d.Status.ReadyReplicas, *d.Spec.Replicas) - } - } -} diff --git a/internal/k8s/actions/update_instance.go b/internal/k8s/actions/update_instance.go index 640f0d9..b6fa1cf 100644 --- a/internal/k8s/actions/update_instance.go +++ b/internal/k8s/actions/update_instance.go @@ -80,7 +80,7 @@ func UpdateInstanceCmd() *cobra.Command { return fmt.Errorf("waiting for instance health: %w", err) } - logger.Info("✓ Instance updated successfully") + logger.Info("Instance updated successfully") return nil } From d0d9b8709208458f742749b22736ba6f85450437 Mon Sep 17 00:00:00 2001 From: aantoni Date: Wed, 28 Jan 2026 12:37:07 +0100 Subject: [PATCH 06/28] Optimize kubernetes client use in apply.go via caching, remove fallback for namespaced resources with empty namespace --- internal/k8s/actions/apply.go | 21 ++++++------------ internal/k8s/client/client.go | 41 ++++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/internal/k8s/actions/apply.go b/internal/k8s/actions/apply.go index 083863c..89d57d2 100644 --- a/internal/k8s/actions/apply.go +++ b/internal/k8s/actions/apply.go @@ -13,9 +13,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/yaml" - "k8s.io/client-go/discovery" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/restmapper" ) // applyManifest applies a single YAML manifest file using RESTMapper @@ -37,17 +34,11 @@ func applyManifest(ctx context.Context, k8sClient *client.Client, manifestPath s namespace = obj.GetName() } - discoveryClient, err := discovery.NewDiscoveryClientForConfig(k8sClient.Config()) + mapper, err := k8sClient.RESTMapper() if err != nil { - return "", fmt.Errorf("creating discovery client: %w", err) + return "", fmt.Errorf("getting REST mapper: %w", err) } - apiGroupResources, err := restmapper.GetAPIGroupResources(discoveryClient) - if err != nil { - return "", fmt.Errorf("getting API group resources: %w", err) - } - - mapper := restmapper.NewDiscoveryRESTMapper(apiGroupResources) gvk := obj.GroupVersionKind() mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version) @@ -55,15 +46,16 @@ func applyManifest(ctx context.Context, k8sClient *client.Client, manifestPath s return "", fmt.Errorf("getting REST mapping for %s: %w", gvk.String(), err) } - dynamicClient, err := dynamic.NewForConfig(k8sClient.Config()) + dynamicClient, err := k8sClient.Dynamic() if err != nil { - return "", fmt.Errorf("creating dynamic client: %w", err) + return "", fmt.Errorf("getting dynamic client: %w", err) } var result *unstructured.Unstructured if mapping.Scope.Name() == meta.RESTScopeNameNamespace { if namespace == "" { - namespace = "default" + return "", fmt.Errorf("resource %s/%s is namespaced but has no namespace specified", + obj.GetKind(), obj.GetName()) } result, err = dynamicClient.Resource(mapping.Resource).Namespace(namespace).Apply( ctx, @@ -75,6 +67,7 @@ func applyManifest(ctx context.Context, k8sClient *client.Client, manifestPath s }, ) } else { + // Cluster-scoped resource (Namespace, ClusterRole, etc.) result, err = dynamicClient.Resource(mapping.Resource).Apply( ctx, obj.GetName(), diff --git a/internal/k8s/client/client.go b/internal/k8s/client/client.go index fc1426a..bc84e93 100644 --- a/internal/k8s/client/client.go +++ b/internal/k8s/client/client.go @@ -4,16 +4,28 @@ import ( "fmt" "os" "path/filepath" + "sync" "github.com/OpenSlides/openslides-cli/internal/logger" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" "k8s.io/client-go/tools/clientcmd" ) type Client struct { clientset *kubernetes.Clientset config *rest.Config + + dynamicClient dynamic.Interface + dynamicOnce sync.Once + dynamicErr error + + restMapper meta.RESTMapper + mapperOnce sync.Once + mapperErr error } // New creates a Kubernetes client @@ -38,7 +50,6 @@ func New(kubeconfigPath string) (*Client, error) { return nil, fmt.Errorf("failed to get in-cluster config and HOME env var not set") } kubeconfigPath = filepath.Join(home, ".kube", "config") - config, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath) if err != nil { return nil, fmt.Errorf("failed to create k8s config: not running in-cluster and no valid kubeconfig found at %s: %w", kubeconfigPath, err) @@ -69,3 +80,31 @@ func (c *Client) Clientset() *kubernetes.Clientset { func (c *Client) Config() *rest.Config { return c.config } + +// Dynamic returns a cached dynamic client, creating it on first call +func (c *Client) Dynamic() (dynamic.Interface, error) { + c.dynamicOnce.Do(func() { + c.dynamicClient, c.dynamicErr = dynamic.NewForConfig(c.config) + if c.dynamicErr == nil { + logger.Debug("Dynamic client initialized") + } + }) + return c.dynamicClient, c.dynamicErr +} + +// RESTMapper returns a cached REST mapper, creating it on first call +func (c *Client) RESTMapper() (meta.RESTMapper, error) { + c.mapperOnce.Do(func() { + apiGroupResources, err := restmapper.GetAPIGroupResources(c.clientset.Discovery()) + if err != nil { + c.mapperErr = fmt.Errorf("getting API group resources: %w", err) + return + } + + c.restMapper = restmapper.NewDiscoveryRESTMapper(apiGroupResources) + + logger.Debug("REST mapper initialized") + }) + + return c.restMapper, c.mapperErr +} From 981f8bdfbc8243cee8311ac73e55e70a1960cc8e Mon Sep 17 00:00:00 2001 From: aantoni Date: Wed, 28 Jan 2026 16:59:09 +0100 Subject: [PATCH 07/28] refactor health_check.go to add better UX for instance pod and deployment checks --- internal/k8s/actions/health.go | 41 -------- internal/k8s/actions/health_check.go | 150 ++++++++++++++++++++++----- internal/k8s/actions/start.go | 5 +- 3 files changed, 125 insertions(+), 71 deletions(-) diff --git a/internal/k8s/actions/health.go b/internal/k8s/actions/health.go index 004acd2..86c3a73 100644 --- a/internal/k8s/actions/health.go +++ b/internal/k8s/actions/health.go @@ -8,8 +8,6 @@ import ( "github.com/OpenSlides/openslides-cli/internal/k8s/client" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/spf13/cobra" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) const ( @@ -55,42 +53,3 @@ func HealthCmd() *cobra.Command { return cmd } - -// checkHealth checks the current health status and prints details -func checkHealth(ctx context.Context, k8sClient *client.Client, namespace string) error { - pods, err := k8sClient.Clientset().CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) - if err != nil { - return fmt.Errorf("listing pods: %w", err) - } - - totalPods := len(pods.Items) - if totalPods == 0 { - return fmt.Errorf("no pods found in namespace %s", namespace) - } - - readyPods := 0 - fmt.Printf("Namespace: %s\n", namespace) - fmt.Println("Pod Status:") - - for _, pod := range pods.Items { - ready := isPodReady(&pod) - if ready { - readyPods++ - } - - status := "✗" - if ready { - status = "✓" - } - fmt.Printf(" %s %-50s %s\n", status, pod.Name, pod.Status.Phase) - } - - fmt.Printf("\nReady: %d/%d pods\n", readyPods, totalPods) - - if readyPods != totalPods { - return fmt.Errorf("instance is not healthy") - } - - logger.Info("Instance is healthy") - return nil -} diff --git a/internal/k8s/actions/health_check.go b/internal/k8s/actions/health_check.go index 64ff76f..dd8da34 100644 --- a/internal/k8s/actions/health_check.go +++ b/internal/k8s/actions/health_check.go @@ -8,11 +8,91 @@ import ( "github.com/OpenSlides/openslides-cli/internal/k8s/client" "github.com/OpenSlides/openslides-cli/internal/logger" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// HealthStatus represents the health status of an instance +type HealthStatus struct { + Healthy bool + Ready int + Total int + Pods []corev1.Pod +} + +// getHealthStatus returns health metrics +func getHealthStatus(ctx context.Context, k8sClient *client.Client, namespace string) (*HealthStatus, error) { + pods, err := k8sClient.Clientset().CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("listing pods: %w", err) + } + + total := len(pods.Items) + if total == 0 { + return &HealthStatus{ + Healthy: false, + Ready: 0, + Total: 0, + Pods: nil, + }, nil + } + + ready := 0 + for _, pod := range pods.Items { + if isPodReady(&pod) { + ready++ + } + } + + return &HealthStatus{ + Healthy: ready == total, + Ready: ready, + Total: total, + Pods: pods.Items, + }, nil +} + +// Helper to print instance pod status +func printHealthStatus(namespace string, status *HealthStatus) { + if status.Total == 0 { + fmt.Printf("No pods found in namespace %s\n", namespace) + return + } + + fmt.Printf("\nNamespace: %s\n", namespace) + fmt.Printf("Ready: %d/%d pods\n\n", status.Ready, status.Total) + fmt.Println("Pod Status:") + + for _, pod := range status.Pods { + ready := isPodReady(&pod) + icon := "✗" + if ready { + icon = "✓" + } + fmt.Printf(" %s %-50s %s\n", icon, pod.Name, pod.Status.Phase) + } + fmt.Println() +} + +// checkHealth checks the current health status and prints details +func checkHealth(ctx context.Context, k8sClient *client.Client, namespace string) error { + status, err := getHealthStatus(ctx, k8sClient, namespace) + if err != nil { + return fmt.Errorf("getting health status: %w", err) + } + + printHealthStatus(namespace, status) + + if !status.Healthy { + return fmt.Errorf("instance is not healthy: %d/%d pods ready", status.Ready, status.Total) + } + + logger.Info("Instance is healthy") + return nil +} + // waitForHealthy waits for instance to become healthy func waitForHealthy(ctx context.Context, k8sClient *client.Client, namespace string, timeout time.Duration) error { logger.Info("Waiting for instance to become healthy (timeout: %v)", timeout) @@ -23,51 +103,34 @@ func waitForHealthy(ctx context.Context, k8sClient *client.Client, namespace str timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + var lastStatus *HealthStatus for { select { case <-ticker.C: - healthy, ready, total, err := getHealthStatus(ctx, k8sClient, namespace) + status, err := getHealthStatus(ctx, k8sClient, namespace) if err != nil { logger.Debug("Error checking health: %v", err) continue } + lastStatus = status - logger.Debug("Health check: %d/%d pods ready", ready, total) + logger.Debug("Health check: %d/%d pods ready", status.Ready, status.Total) - if healthy { - logger.Info("Instance is healthy!") + if status.Healthy { + logger.Info("Instance is healthy: %d/%d pods ready", status.Ready, status.Total) return nil } case <-timeoutCtx.Done(): + logger.Warn("Timeout reached. Current status:") + if lastStatus != nil { + printHealthStatus(namespace, lastStatus) + } return fmt.Errorf("timeout waiting for instance to become healthy") } } } -// getHealthStatus returns health metrics -func getHealthStatus(ctx context.Context, k8sClient *client.Client, namespace string) (healthy bool, ready, total int, err error) { - pods, err := k8sClient.Clientset().CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) - if err != nil { - return false, 0, 0, err - } - - total = len(pods.Items) - if total == 0 { - return false, 0, 0, nil - } - - ready = 0 - for _, pod := range pods.Items { - if isPodReady(&pod) { - ready++ - } - } - - healthy = ready == total - return healthy, ready, total, nil -} - // isPodReady checks if a pod is ready func isPodReady(pod *corev1.Pod) bool { for _, condition := range pod.Status.Conditions { @@ -91,6 +154,31 @@ func namespaceIsActive(ctx context.Context, k8sClient *client.Client, namespace return ns.Status.Phase == corev1.NamespaceActive, nil } +// Helper to print deployment status +func printDeploymentStatus(namespace, name string, deployment *appsv1.Deployment) { + fmt.Printf("\nDeployment: %s (namespace: %s)\n", name, namespace) + fmt.Printf("Generation: %d/%d (observed/current)\n", + deployment.Status.ObservedGeneration, + deployment.Generation) + fmt.Printf("Replicas:\n") + fmt.Printf(" Desired: %d\n", *deployment.Spec.Replicas) + fmt.Printf(" Ready: %d\n", deployment.Status.ReadyReplicas) + fmt.Printf(" Updated: %d\n", deployment.Status.UpdatedReplicas) + fmt.Printf(" Available: %d\n", deployment.Status.AvailableReplicas) + + if len(deployment.Status.Conditions) > 0 { + fmt.Println("\nConditions:") + for _, condition := range deployment.Status.Conditions { + icon := "✓" + if condition.Status != corev1.ConditionTrue { + icon = "✗" + } + fmt.Printf(" %s %-20s %s\n", icon, condition.Type, condition.Message) + } + } + fmt.Println() +} + // waitForDeploymentReady waits for a specific deployment to be ready func waitForDeploymentReady(ctx context.Context, k8sClient *client.Client, namespace, deploymentName string, timeout time.Duration) error { logger.Debug("Waiting for deployment %s to be ready (timeout: %v)", deploymentName, timeout) @@ -101,6 +189,7 @@ func waitForDeploymentReady(ctx context.Context, k8sClient *client.Client, names timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + var lastDeployment *appsv1.Deployment for { select { case <-ticker.C: @@ -110,6 +199,8 @@ func waitForDeploymentReady(ctx context.Context, k8sClient *client.Client, names continue } + lastDeployment = deployment + if deployment.Status.ObservedGeneration >= deployment.Generation && deployment.Status.UpdatedReplicas == *deployment.Spec.Replicas && deployment.Status.AvailableReplicas == *deployment.Spec.Replicas && @@ -127,6 +218,11 @@ func waitForDeploymentReady(ctx context.Context, k8sClient *client.Client, names deployment.Generation) case <-timeoutCtx.Done(): + logger.Warn("Timeout reached. Deployment status:") + if lastDeployment != nil { + printDeploymentStatus(namespace, deploymentName, lastDeployment) + } + return fmt.Errorf("timeout waiting for deployment %s to become ready", deploymentName) } } diff --git a/internal/k8s/actions/start.go b/internal/k8s/actions/start.go index bb2b78e..7d5eac0 100644 --- a/internal/k8s/actions/start.go +++ b/internal/k8s/actions/start.go @@ -36,9 +36,8 @@ func StartCmd() *cobra.Command { timeout := cmd.Flags().Duration("timeout", 3*time.Minute, "Timeout for ready check") cmd.RunE = func(cmd *cobra.Command, args []string) error { - projectDir := args[0] - logger.Info("=== K8S START INSTANCE ===") + projectDir := args[0] logger.Debug("Project directory: %s", projectDir) k8sClient, err := client.New(*kubeconfig) @@ -79,7 +78,7 @@ func StartCmd() *cobra.Command { return fmt.Errorf("waiting for ready: %w", err) } - logger.Info("✓ Instance started successfully") + logger.Info("Instance started successfully") return nil } From b0151b36cc21ccbb62af36769129212a0cf0cfee Mon Sep 17 00:00:00 2001 From: aantoni Date: Thu, 29 Jan 2026 10:58:10 +0100 Subject: [PATCH 08/28] Fix deployment check --- internal/k8s/actions/health_check.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/k8s/actions/health_check.go b/internal/k8s/actions/health_check.go index dd8da34..3e955c8 100644 --- a/internal/k8s/actions/health_check.go +++ b/internal/k8s/actions/health_check.go @@ -162,6 +162,7 @@ func printDeploymentStatus(namespace, name string, deployment *appsv1.Deployment deployment.Generation) fmt.Printf("Replicas:\n") fmt.Printf(" Desired: %d\n", *deployment.Spec.Replicas) + fmt.Printf(" Current: %d\n", deployment.Status.Replicas) fmt.Printf(" Ready: %d\n", deployment.Status.ReadyReplicas) fmt.Printf(" Updated: %d\n", deployment.Status.UpdatedReplicas) fmt.Printf(" Available: %d\n", deployment.Status.AvailableReplicas) @@ -204,16 +205,18 @@ func waitForDeploymentReady(ctx context.Context, k8sClient *client.Client, names if deployment.Status.ObservedGeneration >= deployment.Generation && deployment.Status.UpdatedReplicas == *deployment.Spec.Replicas && deployment.Status.AvailableReplicas == *deployment.Spec.Replicas && - deployment.Status.ReadyReplicas == *deployment.Spec.Replicas { + deployment.Status.ReadyReplicas == *deployment.Spec.Replicas && + deployment.Status.Replicas == *deployment.Spec.Replicas { logger.Info("Deployment %s is ready with %d replicas", deploymentName, *deployment.Spec.Replicas) return nil } - logger.Debug("Deployment %s: %d/%d replicas ready (generation: %d/%d)", + logger.Debug("Deployment %s: %d/%d replicas ready, %d total (generation: %d/%d)", deploymentName, deployment.Status.ReadyReplicas, *deployment.Spec.Replicas, + deployment.Status.Replicas, deployment.Status.ObservedGeneration, deployment.Generation) From 576f02ce59e4f1c3c3563fdcca4d19fa59ae193f Mon Sep 17 00:00:00 2001 From: aantoni Date: Thu, 29 Jan 2026 15:26:02 +0100 Subject: [PATCH 09/28] add patch suggestion --- internal/k8s/actions/update_backendmanage.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/k8s/actions/update_backendmanage.go b/internal/k8s/actions/update_backendmanage.go index cd39b55..58d17c7 100644 --- a/internal/k8s/actions/update_backendmanage.go +++ b/internal/k8s/actions/update_backendmanage.go @@ -87,7 +87,7 @@ func updateBackendmanage(ctx context.Context, k8sClient *client.Client, namespac logger.Info("Updating deployment to image: %s", image) - patch := []byte(fmt.Sprintf(`{"spec":{"template":{"spec":{"containers":[{"name":"%s","image":"%s"}]}}}}`, backendmanageContainer, image)) + patch := fmt.Appendf(nil, `{"spec":{"template":{"spec":{"containers":[{"name":"%s","image":"%s"}]}}}}`, backendmanageContainer, image) updated, err := k8sClient.Clientset().AppsV1().Deployments(namespace).Patch( ctx, @@ -115,7 +115,7 @@ func revertBackendmanage(ctx context.Context, k8sClient *client.Client, namespac logger.Info("Reverting deployment to image: %s", image) - patch := []byte(fmt.Sprintf(`{"spec":{"template":{"spec":{"containers":[{"name":"%s","image":"%s"}]}}}}`, backendmanageContainer, image)) + patch := fmt.Appendf(nil, `{"spec":{"template":{"spec":{"containers":[{"name":"%s","image":"%s"}]}}}}`, backendmanageContainer, image) updated, err := k8sClient.Clientset().AppsV1().Deployments(namespace).Patch( ctx, From 269e9ce1ddf54a7901ba90ca70c454a38e7616b7 Mon Sep 17 00:00:00 2001 From: aantoni Date: Thu, 29 Jan 2026 15:46:47 +0100 Subject: [PATCH 10/28] refactor create.go for permission constants --- internal/k8s/actions/create.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/k8s/actions/create.go b/internal/k8s/actions/create.go index ed50307..ebe260b 100644 --- a/internal/k8s/actions/create.go +++ b/internal/k8s/actions/create.go @@ -26,8 +26,10 @@ Examples: osmanage k8s create ./my.instance.dir.org --db-password "mydbpass" --superadmin-password "myadminpass" osmanage k8s create ./my.instance.dir.org --db-password "$(cat db.txt)" --superadmin-password "$(cat admin.txt)"` - adminSecretsFile = "superadmin" - pgPasswordFile = "postgres_password" + adminSecretsFile = "superadmin" + pgPasswordFile = "postgres_password" + secretDirPerm os.FileMode = 0700 + secretFilePerm os.FileMode = 0600 ) func CreateCmd() *cobra.Command { @@ -84,13 +86,13 @@ func createInstance(projectDir, dbPassword, superadminPassword string) error { pgPasswordPath := filepath.Join(secretsDir, pgPasswordFile) logger.Debug("Writing PostgreSQL password to: %s", pgPasswordPath) - if err := os.WriteFile(pgPasswordPath, []byte(dbPassword), 0600); err != nil { + if err := os.WriteFile(pgPasswordPath, []byte(dbPassword), secretFilePerm); err != nil { return fmt.Errorf("writing postgres password: %w", err) } superadminPath := filepath.Join(secretsDir, adminSecretsFile) logger.Debug("Writing superadmin password to: %s", superadminPath) - if err := os.WriteFile(superadminPath, []byte(superadminPassword), 0600); err != nil { + if err := os.WriteFile(superadminPath, []byte(superadminPassword), secretFilePerm); err != nil { return fmt.Errorf("writing superadmin password: %w", err) } @@ -100,7 +102,7 @@ func createInstance(projectDir, dbPassword, superadminPassword string) error { // secureSecretsDirectory sets restrictive permissions on the secrets directory and all files within func secureSecretsDirectory(secretsDir string) error { - if err := os.Chmod(secretsDir, 0700); err != nil { + if err := os.Chmod(secretsDir, secretDirPerm); err != nil { return fmt.Errorf("setting directory permissions: %w", err) } @@ -115,7 +117,7 @@ func secureSecretsDirectory(secretsDir string) error { } filePath := filepath.Join(secretsDir, entry.Name()) - if err := os.Chmod(filePath, 0600); err != nil { + if err := os.Chmod(filePath, secretFilePerm); err != nil { return fmt.Errorf("setting permissions for %s: %w", entry.Name(), err) } } From 94a1524eac8c93b1a5b22c91a75a958b5af2ca8a Mon Sep 17 00:00:00 2001 From: aantoni Date: Thu, 29 Jan 2026 16:49:58 +0100 Subject: [PATCH 11/28] rename templating dir to instance and move create and remove actions there from k8s --- cmd/osmanage/main.go | 10 +++-- .../{templating => instance}/config/config.go | 0 .../config/config_test.go | 0 .../actions => instance/create}/create.go | 8 ++-- .../create}/create_test.go | 37 ++++++------------- .../actions => instance/remove}/remove.go | 6 +-- .../remove}/remove_test.go | 7 ++-- .../{templating => instance}/setup/setup.go | 20 +++++++--- .../setup/setup_test.go | 0 9 files changed, 44 insertions(+), 44 deletions(-) rename internal/{templating => instance}/config/config.go (100%) rename internal/{templating => instance}/config/config_test.go (100%) rename internal/{k8s/actions => instance/create}/create.go (93%) rename internal/{k8s/actions => instance/create}/create_test.go (89%) rename internal/{k8s/actions => instance/remove}/remove.go (95%) rename internal/{k8s/actions => instance/remove}/remove_test.go (97%) rename internal/{templating => instance}/setup/setup.go (90%) rename internal/{templating => instance}/setup/setup_test.go (100%) diff --git a/cmd/osmanage/main.go b/cmd/osmanage/main.go index a6205b5..d4a66ec 100644 --- a/cmd/osmanage/main.go +++ b/cmd/osmanage/main.go @@ -11,10 +11,12 @@ import ( "github.com/OpenSlides/openslides-cli/internal/actions/migrations" "github.com/OpenSlides/openslides-cli/internal/actions/set" "github.com/OpenSlides/openslides-cli/internal/actions/setpassword" + "github.com/OpenSlides/openslides-cli/internal/instance/config" + "github.com/OpenSlides/openslides-cli/internal/instance/create" + "github.com/OpenSlides/openslides-cli/internal/instance/remove" + "github.com/OpenSlides/openslides-cli/internal/instance/setup" k8sActions "github.com/OpenSlides/openslides-cli/internal/k8s/actions" "github.com/OpenSlides/openslides-cli/internal/logger" - "github.com/OpenSlides/openslides-cli/internal/templating/config" - "github.com/OpenSlides/openslides-cli/internal/templating/setup" "github.com/spf13/cobra" ) @@ -73,8 +75,6 @@ func RootCmd() *cobra.Command { k8sCmd.AddCommand( k8sActions.StartCmd(), k8sActions.StopCmd(), - k8sActions.CreateCmd(), - k8sActions.RemoveCmd(), k8sActions.HealthCmd(), k8sActions.ClusterStatusCmd(), k8sActions.UpdateBackendmanageCmd(), @@ -85,6 +85,8 @@ func RootCmd() *cobra.Command { rootCmd.AddCommand( setup.Cmd(), config.Cmd(), + create.Cmd(), + remove.Cmd(), createuser.Cmd(), initialdata.Cmd(), setpassword.Cmd(), diff --git a/internal/templating/config/config.go b/internal/instance/config/config.go similarity index 100% rename from internal/templating/config/config.go rename to internal/instance/config/config.go diff --git a/internal/templating/config/config_test.go b/internal/instance/config/config_test.go similarity index 100% rename from internal/templating/config/config_test.go rename to internal/instance/config/config_test.go diff --git a/internal/k8s/actions/create.go b/internal/instance/create/create.go similarity index 93% rename from internal/k8s/actions/create.go rename to internal/instance/create/create.go index ebe260b..71763b2 100644 --- a/internal/k8s/actions/create.go +++ b/internal/instance/create/create.go @@ -1,4 +1,4 @@ -package actions +package create import ( "fmt" @@ -23,8 +23,8 @@ This command: The secrets directory must already exist (created by 'setup' command). Examples: - osmanage k8s create ./my.instance.dir.org --db-password "mydbpass" --superadmin-password "myadminpass" - osmanage k8s create ./my.instance.dir.org --db-password "$(cat db.txt)" --superadmin-password "$(cat admin.txt)"` + osmanage create ./my.instance.dir.org --db-password "mydbpass" --superadmin-password "myadminpass" + osmanage create ./my.instance.dir.org --db-password "$(cat db.txt)" --superadmin-password "$(cat admin.txt)"` adminSecretsFile = "superadmin" pgPasswordFile = "postgres_password" @@ -32,7 +32,7 @@ Examples: secretFilePerm os.FileMode = 0600 ) -func CreateCmd() *cobra.Command { +func Cmd() *cobra.Command { cmd := &cobra.Command{ Use: "create ", Short: CreateHelp, diff --git a/internal/k8s/actions/create_test.go b/internal/instance/create/create_test.go similarity index 89% rename from internal/k8s/actions/create_test.go rename to internal/instance/create/create_test.go index 76d679d..9eb9c85 100644 --- a/internal/k8s/actions/create_test.go +++ b/internal/instance/create/create_test.go @@ -1,8 +1,9 @@ -package actions +package create import ( "os" "path/filepath" + "strings" "testing" ) @@ -44,13 +45,13 @@ func TestSecureSecretsDirectory(t *testing.T) { t.Fatalf("Failed to stat secrets directory: %v", err) } - expectedDirPerms := os.FileMode(0700) + expectedDirPerms := os.FileMode(secretDirPerm) if dirInfo.Mode().Perm() != expectedDirPerms { t.Errorf("Directory permissions = %v, want %v", dirInfo.Mode().Perm(), expectedDirPerms) } // Verify all file permissions (600) - expectedFilePerms := os.FileMode(0600) + expectedFilePerms := os.FileMode(secretFilePerm) for _, filename := range testFiles { path := filepath.Join(secretsDir, filename) fileInfo, err := os.Stat(path) @@ -106,8 +107,8 @@ func TestSecureSecretsDirectory_SkipsSubdirectories(t *testing.T) { } // Should still have original 0755 permissions - if subDirInfo.Mode().Perm() == os.FileMode(0600) { - t.Error("Subdirectory permissions should not be changed to 0600") + if subDirInfo.Mode().Perm() == os.FileMode(secretFilePerm) { + t.Error("Subdirectory permissions should not be changed to secretFilePerm") } // Verify file permissions WERE changed @@ -116,7 +117,7 @@ func TestSecureSecretsDirectory_SkipsSubdirectories(t *testing.T) { t.Fatalf("Failed to stat file: %v", err) } - expectedPerms := os.FileMode(0600) + expectedPerms := os.FileMode(secretFilePerm) if fileInfo.Mode().Perm() != expectedPerms { t.Errorf("File permissions = %v, want %v", fileInfo.Mode().Perm(), expectedPerms) } @@ -189,13 +190,13 @@ func TestCreateInstance(t *testing.T) { t.Errorf("internal_auth_password was unexpectedly changed") } - // Verify all files have 0600 permissions + // Verify all files have secretFilePerm permissions entries, err := os.ReadDir(secretsDir) if err != nil { t.Fatalf("Failed to read secrets directory: %v", err) } - expectedPerms := os.FileMode(0600) + expectedPerms := os.FileMode(secretFilePerm) for _, entry := range entries { if entry.IsDir() { continue @@ -212,13 +213,13 @@ func TestCreateInstance(t *testing.T) { } } - // Verify directory has 0700 permissions + // Verify directory has secretDirPerm permissions dirInfo, err := os.Stat(secretsDir) if err != nil { t.Fatalf("Failed to stat secrets directory: %v", err) } - expectedDirPerms := os.FileMode(0700) + expectedDirPerms := os.FileMode(secretDirPerm) if dirInfo.Mode().Perm() != expectedDirPerms { t.Errorf("Directory permissions = %v, want %v", dirInfo.Mode().Perm(), expectedDirPerms) } @@ -243,21 +244,7 @@ func TestCreateInstance_SecretsDirectoryNotExist(t *testing.T) { // Error message should mention running 'setup' first expectedMsg := "run 'setup' first" - if err != nil && !contains(err.Error(), expectedMsg) { + if err != nil && !strings.Contains(err.Error(), expectedMsg) { t.Errorf("Error should mention running 'setup', got: %v", err) } } - -// Helper function to check if string contains substring -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || hasSubstring(s, substr)) -} - -func hasSubstring(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} diff --git a/internal/k8s/actions/remove.go b/internal/instance/remove/remove.go similarity index 95% rename from internal/k8s/actions/remove.go rename to internal/instance/remove/remove.go index 104296a..887de70 100644 --- a/internal/k8s/actions/remove.go +++ b/internal/instance/remove/remove.go @@ -1,4 +1,4 @@ -package actions +package remove import ( "fmt" @@ -16,10 +16,10 @@ WARNING: This operation is irreversible! All configuration files, secrets, and instance data in the directory will be permanently deleted. Examples: - osmanage k8s remove ./my.instance.dir.org --force` + osmanage remove ./my.instance.dir.org --force` ) -func RemoveCmd() *cobra.Command { +func Cmd() *cobra.Command { cmd := &cobra.Command{ Use: "remove ", Short: RemoveHelp, diff --git a/internal/k8s/actions/remove_test.go b/internal/instance/remove/remove_test.go similarity index 97% rename from internal/k8s/actions/remove_test.go rename to internal/instance/remove/remove_test.go index fed3b7e..9e1f8ce 100644 --- a/internal/k8s/actions/remove_test.go +++ b/internal/instance/remove/remove_test.go @@ -1,8 +1,9 @@ -package actions +package remove import ( "os" "path/filepath" + "strings" "testing" ) @@ -62,7 +63,7 @@ func TestRemoveInstance_DirectoryNotExist(t *testing.T) { } expectedMsg := "does not exist" - if err != nil && !contains(err.Error(), expectedMsg) { + if err != nil && !strings.Contains(err.Error(), expectedMsg) { t.Errorf("Error should mention directory doesn't exist, got: %v", err) } } @@ -89,7 +90,7 @@ func TestRemoveInstance_NotADirectory(t *testing.T) { } expectedMsg := "not a directory" - if err != nil && !contains(err.Error(), expectedMsg) { + if err != nil && !strings.Contains(err.Error(), expectedMsg) { t.Errorf("Error should mention it's not a directory, got: %v", err) } } diff --git a/internal/templating/setup/setup.go b/internal/instance/setup/setup.go similarity index 90% rename from internal/templating/setup/setup.go rename to internal/instance/setup/setup.go index 6bce326..d390286 100644 --- a/internal/templating/setup/setup.go +++ b/internal/instance/setup/setup.go @@ -17,8 +17,8 @@ import ( "path/filepath" "time" + "github.com/OpenSlides/openslides-cli/internal/instance/config" "github.com/OpenSlides/openslides-cli/internal/logger" - "github.com/OpenSlides/openslides-cli/internal/templating/config" "github.com/OpenSlides/openslides-cli/internal/utils" "github.com/spf13/cobra" @@ -26,9 +26,19 @@ import ( const ( SetupHelp = "Creates the required files for deployment" - SetupHelpExtra = `This command creates deployment configuration files (Docker Compose or Kubernetes). -It also creates the required secrets and directories for volumes containing persistent -database and SSL certs. Everything is created in the given directory.` + SetupHelpExtra = `Creates deployment configuration files and generates secrets for an OpenSlides instance. + +This command: +1. Creates secrets directory with secure permissions +2. Generates authentication tokens and passwords +3. Creates SSL certificates (if enableLocalHTTPS: true) +4. Generates deployment files from templates + +Examples: + osmanage setup ./my.instance.dir.org + osmanage setup ./my.instance.dir.org --force + osmanage setup ./my.instance.dir.org --template ./custom --config ./config.yaml + osmanage setup ./my.instance.dir.org --config ./base.yaml --config ./override.yaml` DefaultSuperadminPasswordLength = 20 DefaultPostgresPasswordLength = 40 @@ -54,7 +64,7 @@ var defaultSecrets = []SecretSpec{ func Cmd() *cobra.Command { cmd := &cobra.Command{ - Use: "setup directory", + Use: "setup ", Short: SetupHelp, Long: SetupHelp + "\n\n" + SetupHelpExtra, Args: cobra.ExactArgs(1), diff --git a/internal/templating/setup/setup_test.go b/internal/instance/setup/setup_test.go similarity index 100% rename from internal/templating/setup/setup_test.go rename to internal/instance/setup/setup_test.go From a6bbf84de5ec47a015b872d5482268488410bdaa Mon Sep 17 00:00:00 2001 From: aantoni Date: Thu, 29 Jan 2026 17:31:55 +0100 Subject: [PATCH 12/28] move bakendmanage actions and client to manage folder --- internal/{ => manage}/actions/action/action.go | 2 +- internal/{ => manage}/actions/createuser/createuser.go | 2 +- internal/{ => manage}/actions/get/get.go | 0 internal/{ => manage}/actions/get/get_test.go | 0 internal/{ => manage}/actions/initialdata/initialdata.go | 2 +- internal/{ => manage}/actions/integration_test.go | 0 internal/{ => manage}/actions/migrations/migrations.go | 2 +- internal/{ => manage}/actions/migrations/migrations_test.go | 0 internal/{ => manage}/actions/set/set.go | 2 +- internal/{ => manage}/actions/set/set_test.go | 0 internal/{ => manage}/actions/setpassword/setpassword.go | 2 +- internal/{ => manage}/client/client.go | 0 internal/{ => manage}/client/client_test.go | 0 13 files changed, 6 insertions(+), 6 deletions(-) rename internal/{ => manage}/actions/action/action.go (97%) rename internal/{ => manage}/actions/createuser/createuser.go (97%) rename internal/{ => manage}/actions/get/get.go (100%) rename internal/{ => manage}/actions/get/get_test.go (100%) rename internal/{ => manage}/actions/initialdata/initialdata.go (98%) rename internal/{ => manage}/actions/integration_test.go (100%) rename internal/{ => manage}/actions/migrations/migrations.go (99%) rename internal/{ => manage}/actions/migrations/migrations_test.go (100%) rename internal/{ => manage}/actions/set/set.go (98%) rename internal/{ => manage}/actions/set/set_test.go (100%) rename internal/{ => manage}/actions/setpassword/setpassword.go (97%) rename internal/{ => manage}/client/client.go (100%) rename internal/{ => manage}/client/client_test.go (100%) diff --git a/internal/actions/action/action.go b/internal/manage/actions/action/action.go similarity index 97% rename from internal/actions/action/action.go rename to internal/manage/actions/action/action.go index 1d59617..534679e 100644 --- a/internal/actions/action/action.go +++ b/internal/manage/actions/action/action.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/OpenSlides/openslides-cli/internal/client" + "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/OpenSlides/openslides-cli/internal/utils" diff --git a/internal/actions/createuser/createuser.go b/internal/manage/actions/createuser/createuser.go similarity index 97% rename from internal/actions/createuser/createuser.go rename to internal/manage/actions/createuser/createuser.go index a421fff..d32a5e0 100644 --- a/internal/actions/createuser/createuser.go +++ b/internal/manage/actions/createuser/createuser.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/OpenSlides/openslides-cli/internal/client" + "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/OpenSlides/openslides-cli/internal/utils" diff --git a/internal/actions/get/get.go b/internal/manage/actions/get/get.go similarity index 100% rename from internal/actions/get/get.go rename to internal/manage/actions/get/get.go diff --git a/internal/actions/get/get_test.go b/internal/manage/actions/get/get_test.go similarity index 100% rename from internal/actions/get/get_test.go rename to internal/manage/actions/get/get_test.go diff --git a/internal/actions/initialdata/initialdata.go b/internal/manage/actions/initialdata/initialdata.go similarity index 98% rename from internal/actions/initialdata/initialdata.go rename to internal/manage/actions/initialdata/initialdata.go index 2917dcb..9d933e7 100644 --- a/internal/actions/initialdata/initialdata.go +++ b/internal/manage/actions/initialdata/initialdata.go @@ -6,8 +6,8 @@ import ( "fmt" "os" - "github.com/OpenSlides/openslides-cli/internal/client" "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/utils" "github.com/spf13/cobra" diff --git a/internal/actions/integration_test.go b/internal/manage/actions/integration_test.go similarity index 100% rename from internal/actions/integration_test.go rename to internal/manage/actions/integration_test.go diff --git a/internal/actions/migrations/migrations.go b/internal/manage/actions/migrations/migrations.go similarity index 99% rename from internal/actions/migrations/migrations.go rename to internal/manage/actions/migrations/migrations.go index ac4d9bb..c92a04e 100644 --- a/internal/actions/migrations/migrations.go +++ b/internal/manage/actions/migrations/migrations.go @@ -7,8 +7,8 @@ import ( "strings" "time" - "github.com/OpenSlides/openslides-cli/internal/client" "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/utils" "github.com/spf13/cobra" diff --git a/internal/actions/migrations/migrations_test.go b/internal/manage/actions/migrations/migrations_test.go similarity index 100% rename from internal/actions/migrations/migrations_test.go rename to internal/manage/actions/migrations/migrations_test.go diff --git a/internal/actions/set/set.go b/internal/manage/actions/set/set.go similarity index 98% rename from internal/actions/set/set.go rename to internal/manage/actions/set/set.go index ad2425b..a275e1f 100644 --- a/internal/actions/set/set.go +++ b/internal/manage/actions/set/set.go @@ -6,7 +6,7 @@ import ( "sort" "strings" - "github.com/OpenSlides/openslides-cli/internal/client" + "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/OpenSlides/openslides-cli/internal/utils" diff --git a/internal/actions/set/set_test.go b/internal/manage/actions/set/set_test.go similarity index 100% rename from internal/actions/set/set_test.go rename to internal/manage/actions/set/set_test.go diff --git a/internal/actions/setpassword/setpassword.go b/internal/manage/actions/setpassword/setpassword.go similarity index 97% rename from internal/actions/setpassword/setpassword.go rename to internal/manage/actions/setpassword/setpassword.go index c6eb656..aada7ca 100644 --- a/internal/actions/setpassword/setpassword.go +++ b/internal/manage/actions/setpassword/setpassword.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/OpenSlides/openslides-cli/internal/client" + "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/OpenSlides/openslides-cli/internal/utils" diff --git a/internal/client/client.go b/internal/manage/client/client.go similarity index 100% rename from internal/client/client.go rename to internal/manage/client/client.go diff --git a/internal/client/client_test.go b/internal/manage/client/client_test.go similarity index 100% rename from internal/client/client_test.go rename to internal/manage/client/client_test.go From e7f8d67733e57e689ac8d68f5fb15b8da82cbaff Mon Sep 17 00:00:00 2001 From: aantoni Date: Thu, 29 Jan 2026 17:33:01 +0100 Subject: [PATCH 13/28] fix import in main --- cmd/osmanage/main.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd/osmanage/main.go b/cmd/osmanage/main.go index d4a66ec..729eca5 100644 --- a/cmd/osmanage/main.go +++ b/cmd/osmanage/main.go @@ -4,19 +4,19 @@ import ( "fmt" "os" - "github.com/OpenSlides/openslides-cli/internal/actions/action" - "github.com/OpenSlides/openslides-cli/internal/actions/createuser" - "github.com/OpenSlides/openslides-cli/internal/actions/get" - "github.com/OpenSlides/openslides-cli/internal/actions/initialdata" - "github.com/OpenSlides/openslides-cli/internal/actions/migrations" - "github.com/OpenSlides/openslides-cli/internal/actions/set" - "github.com/OpenSlides/openslides-cli/internal/actions/setpassword" "github.com/OpenSlides/openslides-cli/internal/instance/config" "github.com/OpenSlides/openslides-cli/internal/instance/create" "github.com/OpenSlides/openslides-cli/internal/instance/remove" "github.com/OpenSlides/openslides-cli/internal/instance/setup" k8sActions "github.com/OpenSlides/openslides-cli/internal/k8s/actions" "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/OpenSlides/openslides-cli/internal/manage/actions/action" + "github.com/OpenSlides/openslides-cli/internal/manage/actions/createuser" + "github.com/OpenSlides/openslides-cli/internal/manage/actions/get" + "github.com/OpenSlides/openslides-cli/internal/manage/actions/initialdata" + "github.com/OpenSlides/openslides-cli/internal/manage/actions/migrations" + "github.com/OpenSlides/openslides-cli/internal/manage/actions/set" + "github.com/OpenSlides/openslides-cli/internal/manage/actions/setpassword" "github.com/spf13/cobra" ) From 5227280e890db0ef81604df8733822050a48bbf8 Mon Sep 17 00:00:00 2001 From: aantoni Date: Fri, 30 Jan 2026 09:46:44 +0100 Subject: [PATCH 14/28] fix fmt --- internal/manage/actions/action/action.go | 2 +- internal/manage/actions/createuser/createuser.go | 2 +- internal/manage/actions/set/set.go | 2 +- internal/manage/actions/setpassword/setpassword.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/manage/actions/action/action.go b/internal/manage/actions/action/action.go index 534679e..8aa336a 100644 --- a/internal/manage/actions/action/action.go +++ b/internal/manage/actions/action/action.go @@ -4,8 +4,8 @@ import ( "encoding/json" "fmt" - "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/utils" "github.com/spf13/cobra" diff --git a/internal/manage/actions/createuser/createuser.go b/internal/manage/actions/createuser/createuser.go index d32a5e0..45ec97c 100644 --- a/internal/manage/actions/createuser/createuser.go +++ b/internal/manage/actions/createuser/createuser.go @@ -4,8 +4,8 @@ import ( "encoding/json" "fmt" - "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/utils" "github.com/spf13/cobra" diff --git a/internal/manage/actions/set/set.go b/internal/manage/actions/set/set.go index a275e1f..885cc8b 100644 --- a/internal/manage/actions/set/set.go +++ b/internal/manage/actions/set/set.go @@ -6,8 +6,8 @@ import ( "sort" "strings" - "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/utils" "github.com/spf13/cobra" diff --git a/internal/manage/actions/setpassword/setpassword.go b/internal/manage/actions/setpassword/setpassword.go index aada7ca..4b5d5a4 100644 --- a/internal/manage/actions/setpassword/setpassword.go +++ b/internal/manage/actions/setpassword/setpassword.go @@ -4,8 +4,8 @@ import ( "encoding/json" "fmt" - "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/utils" "github.com/spf13/cobra" From 67f94275ab6423c677e435a074edeb86ff66e072 Mon Sep 17 00:00:00 2001 From: aantoni Date: Mon, 2 Feb 2026 10:19:19 +0100 Subject: [PATCH 15/28] Change constant type to fs.FileMode --- internal/instance/create/create.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/instance/create/create.go b/internal/instance/create/create.go index 71763b2..67f3347 100644 --- a/internal/instance/create/create.go +++ b/internal/instance/create/create.go @@ -2,6 +2,7 @@ package create import ( "fmt" + "io/fs" "os" "path/filepath" @@ -28,8 +29,8 @@ Examples: adminSecretsFile = "superadmin" pgPasswordFile = "postgres_password" - secretDirPerm os.FileMode = 0700 - secretFilePerm os.FileMode = 0600 + secretDirPerm fs.FileMode = 0700 + secretFilePerm fs.FileMode = 0600 ) func Cmd() *cobra.Command { From 5a62558ff0b3ac8fbb4b11f99d67e4dd59fcd050 Mon Sep 17 00:00:00 2001 From: aantoni Date: Mon, 2 Feb 2026 10:26:13 +0100 Subject: [PATCH 16/28] Add progress bar to waitForHealthy in health_check.go (pod check) --- go.mod | 3 +++ go.sum | 10 ++++++++ internal/k8s/actions/health_check.go | 36 +++++++++++++++++++++++++++- 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index cfc45eb..d2bd319 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.1 require ( github.com/OpenSlides/openslides-go v0.0.0-20251104124242-d8e4b15bb11e + github.com/schollz/progressbar/v3 v3.19.0 github.com/shopspring/decimal v1.4.0 github.com/spf13/cobra v1.10.2 golang.org/x/text v0.33.0 @@ -30,9 +31,11 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect diff --git a/go.sum b/go.sum index a8ff340..41ff7eb 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/OpenSlides/openslides-go v0.0.0-20251104124242-d8e4b15bb11e h1:pRKc33 github.com/OpenSlides/openslides-go v0.0.0-20251104124242-d8e4b15bb11e/go.mod h1:Em6jcRrIaNDy6pkWLJx5gLLFO61th+GNQCmD/0AQPtY= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= +github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -85,6 +87,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +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/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= @@ -115,9 +121,13 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= +github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= diff --git a/internal/k8s/actions/health_check.go b/internal/k8s/actions/health_check.go index 3e955c8..922bafd 100644 --- a/internal/k8s/actions/health_check.go +++ b/internal/k8s/actions/health_check.go @@ -7,6 +7,7 @@ import ( "github.com/OpenSlides/openslides-cli/internal/k8s/client" "github.com/OpenSlides/openslides-cli/internal/logger" + "github.com/schollz/progressbar/v3" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -14,6 +15,9 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// Width of the progress bar for wait functions +const progressBarWidth = 40 + // HealthStatus represents the health status of an instance type HealthStatus struct { Healthy bool @@ -104,6 +108,8 @@ func waitForHealthy(ctx context.Context, k8sClient *client.Client, namespace str defer cancel() var lastStatus *HealthStatus + var bar *progressbar.ProgressBar + for { select { case <-ticker.C: @@ -114,14 +120,26 @@ func waitForHealthy(ctx context.Context, k8sClient *client.Client, namespace str } lastStatus = status - logger.Debug("Health check: %d/%d pods ready", status.Ready, status.Total) + if bar == nil && status.Total > 0 { + bar = createProgressBar(status.Total, "Pods ready") + } + + if bar != nil { + bar.Set(status.Ready) + } if status.Healthy { + if bar != nil { + bar.Finish() + } logger.Info("Instance is healthy: %d/%d pods ready", status.Ready, status.Total) return nil } case <-timeoutCtx.Done(): + if bar != nil { + bar.Finish() + } logger.Warn("Timeout reached. Current status:") if lastStatus != nil { printHealthStatus(namespace, lastStatus) @@ -131,6 +149,22 @@ func waitForHealthy(ctx context.Context, k8sClient *client.Client, namespace str } } +func createProgressBar(max int, description string) *progressbar.ProgressBar { + return progressbar.NewOptions(max, + progressbar.OptionSetDescription(description), + progressbar.OptionSetWidth(progressBarWidth), + progressbar.OptionShowCount(), + progressbar.OptionSetTheme(progressbar.Theme{ + Saucer: "█", + SaucerPadding: "░", + BarStart: "[", + BarEnd: "]", + }), + progressbar.OptionThrottle(100*time.Millisecond), + progressbar.OptionClearOnFinish(), + ) +} + // isPodReady checks if a pod is ready func isPodReady(pod *corev1.Pod) bool { for _, condition := range pod.Status.Conditions { From c24b9b27373d610dbacd61f99177069e427775fc Mon Sep 17 00:00:00 2001 From: aantoni Date: Mon, 2 Feb 2026 11:28:55 +0100 Subject: [PATCH 17/28] change --force default to false, update description, secretsDirRoot const --- internal/instance/config/config.go | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/internal/instance/config/config.go b/internal/instance/config/config.go index 97b1ceb..3b33369 100644 --- a/internal/instance/config/config.go +++ b/internal/instance/config/config.go @@ -22,21 +22,36 @@ import ( const ( ConfigHelp = "(Re)creates deployment configuration files" - ConfigHelpExtra = `This command (re)creates the deployment file(s) in the given directory. -For Docker Compose, this creates a docker-compose.yml file. -For Kubernetes, this creates a directory with multiple YAML manifests.` + ConfigHelpExtra = `(Re)creates deployment configuration files from templates. + +Generates deployment files (Docker Compose or Kubernetes manifests) using +templates and YAML configuration files. Multiple config files are deep-merged +in order, with later files overriding earlier ones. + +Template functions available: + • marshalContent - Marshal YAML content with indentation + • envMapToK8S - Convert environment map to Kubernetes format + • readSecret - Read and base64-encode secrets from secrets/ directory + +Examples: + osmanage config ./my.instance.dir.org + osmanage config ./my.instance.dir.org --template ./custom.tmpl --config ./config.yaml + osmanage config ./my.instance.dir.org -t ./k8s-templates -c base.yaml -c overrides.yaml + osmanage config ./my.instance.dir.org --force` + + secretsDirRoot string = "secrets" ) // Cmd returns the subcommand. func Cmd() *cobra.Command { cmd := &cobra.Command{ - Use: "config directory", + Use: "config ", Short: ConfigHelp, Long: ConfigHelp + "\n\n" + ConfigHelpExtra, Args: cobra.ExactArgs(1), } - force := cmd.Flags().BoolP("force", "f", true, "overwrite existing files") + force := cmd.Flags().BoolP("force", "f", false, "overwrite existing files") customTemplate := cmd.Flags().StringP("template", "t", "", "custom template file or directory") configFiles := cmd.Flags().StringArrayP("config", "c", nil, "custom YAML config file (can be used multiple times)") cmd.MarkFlagsRequiredTogether("template", "config") @@ -205,7 +220,7 @@ func (tf *TemplateFunctions) GetFuncMap() template.FuncMap { // ReadSecret reads a secret file from the secrets directory and returns it base64 encoded func (tf *TemplateFunctions) ReadSecret(name string) (string, error) { - secretPath := filepath.Join(tf.baseDir, "secrets", name) + secretPath := filepath.Join(tf.baseDir, secretsDirRoot, name) data, err := os.ReadFile(secretPath) if err != nil { if errors.Is(err, os.ErrNotExist) { From f8385bc4b2a648aad693cecd1051f0dfb84c5536 Mon Sep 17 00:00:00 2001 From: aantoni Date: Mon, 2 Feb 2026 18:09:34 +0100 Subject: [PATCH 18/28] Refactor constants and file permissions to centralized internal/constants package --- internal/constants/constants.go | 86 ++++++++++++++ internal/instance/config/config.go | 35 ++++-- internal/instance/config/config_test.go | 38 +++--- internal/instance/create/create.go | 33 +++--- internal/instance/create/create_test.go | 79 ++++++------- internal/instance/remove/remove.go | 24 ++-- internal/instance/remove/remove_test.go | 100 ++++++++-------- internal/instance/setup/setup.go | 38 +++--- internal/instance/setup/setup_test.go | 116 ++++++++++++------- internal/k8s/actions/apply.go | 16 ++- internal/k8s/actions/health.go | 10 +- internal/k8s/actions/health_check.go | 50 ++++++-- internal/k8s/actions/helpers.go | 8 +- internal/k8s/actions/scale.go | 18 +-- internal/k8s/actions/start.go | 22 ++-- internal/k8s/actions/stop.go | 63 +++------- internal/k8s/actions/update_backendmanage.go | 24 ++-- internal/k8s/actions/update_instance.go | 20 ++-- internal/k8s/client/client.go | 43 +++++-- internal/utils/utils.go | 6 +- internal/utils/utils_test.go | 71 +++++++++++- 21 files changed, 560 insertions(+), 340 deletions(-) create mode 100644 internal/constants/constants.go diff --git a/internal/constants/constants.go b/internal/constants/constants.go new file mode 100644 index 0000000..ca3a626 --- /dev/null +++ b/internal/constants/constants.go @@ -0,0 +1,86 @@ +// Package constants defines project-wide constants used across osmanage. +// These represent the standard OpenSlides instance directory structure +// and file permissions. +package constants + +import ( + "io/fs" + "time" +) + +// Instance directory structure +const ( + // StackDirName is the directory containing Kubernetes manifests + StackDirName string = "stack" + + // SecretsDirName is the directory containing sensitive files + SecretsDirName string = "secrets" + + // TLS certificate secret name and filename for HTTPS + TlsCertSecret string = "tls-letsencrypt" // Kubernetes Secret resource name + TlsCertSecretYAML string = "tls-letsencrypt-secret.yaml" // Secret manifest filename + + // The template string for OpenSlides deployment filenames i. e. autoupdate-deployment.yaml + DeploymentFileTemplate string = "%s-deployment.yaml" +) + +// File permissions +const ( + // SecretsDirPerm is the permission for the secrets directory (owner only) + SecretsDirPerm fs.FileMode = 0700 + + // SecretFilePerm is the permission for secret files (owner read/write only) + SecretFilePerm fs.FileMode = 0600 + + // InstanceDirPerm is the permission for project root directory (owner + others read) + InstanceDirPerm fs.FileMode = 0755 + + // StackDirPerm is the permission for the stack directory (owner + others read) + StackDirPerm fs.FileMode = 0755 + + // StackFilePerm is the permission for manifest files (owner write, others read) + StackFilePerm fs.FileMode = 0644 +) + +// Password generation defaults for setup +const ( + DefaultSuperadminPasswordLength int = 20 + DefaultPostgresPasswordLength int = 40 +) + +// Certificate file names (for HTTPS) for setup +const ( + CertCertName string = "cert_crt" // Certificate file + CertKeyName string = "cert_key" // Private key file +) + +// Secret file names +const ( + // AdminSecretsFile contains the superadmin password + AdminSecretsFile string = "superadmin" + + // PgPasswordFile contains the PostgreSQL database password + PgPasswordFile string = "postgres_password" + + // AuthTokenKey contains the authentication token secret + AuthTokenKey string = "auth_token_key" + + // AuthCookieKey contains the cookie signing secret + AuthCookieKey string = "auth_cookie_key" + + // InternalAuthPassword contains the internal service authentication password + InternalAuthPassword string = "internal_auth_password" +) + +// Default timeouts for Kubernetes operations +const ( + DefaultInstanceTimeout time.Duration = 3 * time.Minute // Wait for all instance pods to become ready + DefaultDeploymentTimeout time.Duration = 3 * time.Minute // Wait for deployment rollout to complete + DefaultNamespaceTimeout time.Duration = 5 * time.Minute // Wait for namespace deletion (includes finalizers) +) + +// OpenSlides K8s resource names +const ( + BackendmanageDeploymentName string = "backendmanage" + BackendmanageContainerName string = "backendmanage" +) diff --git a/internal/instance/config/config.go b/internal/instance/config/config.go index 3b33369..17a0c12 100644 --- a/internal/instance/config/config.go +++ b/internal/instance/config/config.go @@ -12,6 +12,7 @@ import ( "strings" "text/template" + "github.com/OpenSlides/openslides-cli/internal/constants" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/OpenSlides/openslides-cli/internal/utils" @@ -38,14 +39,12 @@ Examples: osmanage config ./my.instance.dir.org --template ./custom.tmpl --config ./config.yaml osmanage config ./my.instance.dir.org -t ./k8s-templates -c base.yaml -c overrides.yaml osmanage config ./my.instance.dir.org --force` - - secretsDirRoot string = "secrets" ) // Cmd returns the subcommand. func Cmd() *cobra.Command { cmd := &cobra.Command{ - Use: "config ", + Use: "config ", Short: ConfigHelp, Long: ConfigHelp + "\n\n" + ConfigHelpExtra, Args: cobra.ExactArgs(1), @@ -136,8 +135,8 @@ func createFromTemplateFile(baseDir string, force bool, tplFile string, cfg map[ return fmt.Errorf("reading template file: %w", err) } - if err := os.MkdirAll(baseDir, os.ModePerm); err != nil { - return fmt.Errorf("creating directory: %w", err) + if err := os.MkdirAll(baseDir, constants.InstanceDirPerm); err != nil { + return fmt.Errorf("creating instance directory: %w", err) } // Extract filename from config if present, otherwise use a default @@ -150,8 +149,8 @@ func createFromTemplateDir(baseDir string, force bool, tplDir string, cfg map[st tplFS := os.DirFS(tplDir) - if err := os.MkdirAll(baseDir, os.ModePerm); err != nil { - return fmt.Errorf("creating directory: %w", err) + if err := os.MkdirAll(baseDir, constants.InstanceDirPerm); err != nil { + return fmt.Errorf("creating instance directory: %w", err) } return createFromFS(baseDir, force, tplFS, cfg) @@ -166,8 +165,10 @@ func createFromFS(baseDir string, force bool, tplFS fs.FS, cfg map[string]any) e targetPath := filepath.Join(baseDir, path) if d.IsDir() { - logger.Debug("Creating directory: %s", targetPath) - return os.MkdirAll(targetPath, os.ModePerm) + // Use appropriate permissions based on directory name + perm := getDirPermissions(filepath.Base(targetPath)) + logger.Debug("Creating directory: %s (perms: %04o)", targetPath, perm) + return os.MkdirAll(targetPath, perm) } logger.Debug("Processing template: %s", path) @@ -180,6 +181,18 @@ func createFromFS(baseDir string, force bool, tplFS fs.FS, cfg map[string]any) e }) } +// getDirPermissions returns appropriate permissions based on directory name +func getDirPermissions(dirName string) fs.FileMode { + switch dirName { + case constants.SecretsDirName: + return constants.SecretsDirPerm + case constants.StackDirName: + return constants.StackDirPerm + default: + return constants.InstanceDirPerm + } +} + func createDeploymentFile(filename string, force bool, tplData []byte, cfg map[string]any, baseDir string) error { tf := &TemplateFunctions{baseDir: baseDir} tmpl, err := template.New("deployment").Funcs(tf.GetFuncMap()).Parse(string(tplData)) @@ -194,7 +207,7 @@ func createDeploymentFile(filename string, force bool, tplData []byte, cfg map[s dir := filepath.Dir(filename) name := filepath.Base(filename) - return utils.CreateFile(dir, force, name, buf.Bytes()) + return utils.CreateFile(dir, force, name, buf.Bytes(), constants.StackFilePerm) } // getFilename extracts the filename from config, or returns a default @@ -220,7 +233,7 @@ func (tf *TemplateFunctions) GetFuncMap() template.FuncMap { // ReadSecret reads a secret file from the secrets directory and returns it base64 encoded func (tf *TemplateFunctions) ReadSecret(name string) (string, error) { - secretPath := filepath.Join(tf.baseDir, secretsDirRoot, name) + secretPath := filepath.Join(tf.baseDir, constants.SecretsDirName, name) data, err := os.ReadFile(secretPath) if err != nil { if errors.Is(err, os.ErrNotExist) { diff --git a/internal/instance/config/config_test.go b/internal/instance/config/config_test.go index e913c5e..5a7f3cd 100644 --- a/internal/instance/config/config_test.go +++ b/internal/instance/config/config_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/OpenSlides/openslides-cli/internal/constants" ) func TestNewConfig(t *testing.T) { @@ -81,12 +83,12 @@ defaults: tmpdir := t.TempDir() config1 := filepath.Join(tmpdir, "config1.yml") - if err := os.WriteFile(config1, []byte("host: 127.0.0.1\nport: 8000\n"), 0644); err != nil { + if err := os.WriteFile(config1, []byte("host: 127.0.0.1\nport: 8000\n"), constants.StackFilePerm); err != nil { t.Fatalf("failed to write config1: %v", err) } config2 := filepath.Join(tmpdir, "config2.yml") - if err := os.WriteFile(config2, []byte("port: 9000\nstackName: test-stack\n"), 0644); err != nil { + if err := os.WriteFile(config2, []byte("port: 9000\nstackName: test-stack\n"), constants.StackFilePerm); err != nil { t.Fatalf("failed to write config2: %v", err) } @@ -117,7 +119,7 @@ services: client: tag: latest replicas: 3 -`), 0644); err != nil { +`), constants.StackFilePerm); err != nil { t.Fatalf("failed to write config1: %v", err) } @@ -127,7 +129,7 @@ services: client: foo: bar replicas: 5 -`), 0644); err != nil { +`), constants.StackFilePerm); err != nil { t.Fatalf("failed to write config2: %v", err) } @@ -168,7 +170,7 @@ services: services: client: tag: latest -`), 0644); err != nil { +`), constants.StackFilePerm); err != nil { t.Fatalf("failed to write config1: %v", err) } @@ -180,7 +182,7 @@ services: deeply: nested: value: 42 -`), 0644); err != nil { +`), constants.StackFilePerm); err != nil { t.Fatalf("failed to write config2: %v", err) } @@ -215,7 +217,7 @@ services: defaults: containerRegistry: registry.example.com tag: latest -`), 0644); err != nil { +`), constants.StackFilePerm); err != nil { t.Fatalf("failed to write config1: %v", err) } @@ -229,7 +231,7 @@ services: password: super-secret projector: replicas: 3 -`), 0644); err != nil { +`), constants.StackFilePerm); err != nil { t.Fatalf("failed to write config2: %v", err) } @@ -344,11 +346,11 @@ func TestGetFilename(t *testing.T) { func TestTemplateFunctions(t *testing.T) { tmpdir := t.TempDir() - secretsDir := filepath.Join(tmpdir, "secrets") - if err := os.MkdirAll(secretsDir, 0755); err != nil { + secretsDir := filepath.Join(tmpdir, constants.SecretsDirName) + if err := os.MkdirAll(secretsDir, constants.SecretsDirPerm); err != nil { t.Fatalf("failed to create secrets dir: %v", err) } - if err := os.WriteFile(filepath.Join(secretsDir, "test_secret"), []byte("secret123"), 0644); err != nil { + if err := os.WriteFile(filepath.Join(secretsDir, "test_secret"), []byte("secret123"), constants.SecretFilePerm); err != nil { t.Fatalf("failed to write test secret: %v", err) } @@ -477,7 +479,7 @@ func TestCreateDirAndFiles(t *testing.T) { t.Run("template file", func(t *testing.T) { tplFile := filepath.Join(tmpdir, "template.yml") - if err := os.WriteFile(tplFile, []byte("test: {{ .url }}"), 0644); err != nil { + if err := os.WriteFile(tplFile, []byte("test: {{ .url }}"), constants.StackFilePerm); err != nil { t.Fatalf("failed to write template: %v", err) } @@ -503,13 +505,13 @@ func TestCreateDirAndFiles(t *testing.T) { t.Run("template directory", func(t *testing.T) { tplDir := filepath.Join(tmpdir, "templates") - if err := os.MkdirAll(tplDir, 0755); err != nil { + if err := os.MkdirAll(tplDir, constants.StackDirPerm); err != nil { t.Fatalf("failed to create template dir: %v", err) } - if err := os.WriteFile(filepath.Join(tplDir, "file1.yml"), []byte("content1"), 0644); err != nil { + if err := os.WriteFile(filepath.Join(tplDir, "file1.yml"), []byte("content1"), constants.StackFilePerm); err != nil { t.Fatalf("failed to write file1: %v", err) } - if err := os.WriteFile(filepath.Join(tplDir, "file2.yml"), []byte("content2"), 0644); err != nil { + if err := os.WriteFile(filepath.Join(tplDir, "file2.yml"), []byte("content2"), constants.StackFilePerm); err != nil { t.Fatalf("failed to write file2: %v", err) } @@ -534,7 +536,7 @@ func TestCreateDirAndFiles(t *testing.T) { t.Run("template with nested config access", func(t *testing.T) { tplFile := filepath.Join(tmpdir, "nested-template.yml") - if err := os.WriteFile(tplFile, []byte("foo: {{ .services.client.foo }}\ntag: {{ .services.client.tag }}"), 0644); err != nil { + if err := os.WriteFile(tplFile, []byte("foo: {{ .services.client.foo }}\ntag: {{ .services.client.tag }}"), constants.StackFilePerm); err != nil { t.Fatalf("failed to write template: %v", err) } @@ -570,7 +572,7 @@ func TestCreateDirAndFiles(t *testing.T) { t.Run("template with or operator fallback pattern", func(t *testing.T) { tplFile := filepath.Join(tmpdir, "or-template.yml") if err := os.WriteFile(tplFile, []byte(`tag: {{ or .services.auth.tag .defaults.tag }} -registry: {{ or .services.auth.containerRegistry .defaults.containerRegistry }}`), 0644); err != nil { +registry: {{ or .services.auth.containerRegistry .defaults.containerRegistry }}`), constants.StackFilePerm); err != nil { t.Fatalf("failed to write template: %v", err) } @@ -616,7 +618,7 @@ registry: {{ or .services.auth.containerRegistry .defaults.containerRegistry }}` projector: image: {{ or .services.projector.containerRegistry .defaults.containerRegistry }}/openslides-projector:{{ or .services.projector.tag .defaults.tag }} replicas: {{ or .services.projector.replicas 1 }}` - if err := os.WriteFile(tplFile, []byte(templateContent), 0644); err != nil { + if err := os.WriteFile(tplFile, []byte(templateContent), constants.StackFilePerm); err != nil { t.Fatalf("failed to write template: %v", err) } diff --git a/internal/instance/create/create.go b/internal/instance/create/create.go index 67f3347..7f37939 100644 --- a/internal/instance/create/create.go +++ b/internal/instance/create/create.go @@ -2,10 +2,10 @@ package create import ( "fmt" - "io/fs" "os" "path/filepath" + "github.com/OpenSlides/openslides-cli/internal/constants" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/spf13/cobra" ) @@ -26,16 +26,11 @@ The secrets directory must already exist (created by 'setup' command). Examples: osmanage create ./my.instance.dir.org --db-password "mydbpass" --superadmin-password "myadminpass" osmanage create ./my.instance.dir.org --db-password "$(cat db.txt)" --superadmin-password "$(cat admin.txt)"` - - adminSecretsFile = "superadmin" - pgPasswordFile = "postgres_password" - secretDirPerm fs.FileMode = 0700 - secretFilePerm fs.FileMode = 0600 ) func Cmd() *cobra.Command { cmd := &cobra.Command{ - Use: "create ", + Use: "create ", Short: CreateHelp, Long: CreateHelp + "\n\n" + CreateHelpExtra, Args: cobra.ExactArgs(1), @@ -56,10 +51,10 @@ func Cmd() *cobra.Command { } logger.Info("=== K8S CREATE INSTANCE ===") - projectDir := args[0] - logger.Debug("Project directory: %s", projectDir) + instanceDir := args[0] + logger.Debug("Instance directory: %s", instanceDir) - if err := createInstance(projectDir, *dbPassword, *superadminPassword); err != nil { + if err := createInstance(instanceDir, *dbPassword, *superadminPassword); err != nil { return fmt.Errorf("creating instance: %w", err) } @@ -71,29 +66,29 @@ func Cmd() *cobra.Command { } // createInstance sets up the secrets directory with the provided passwords -func createInstance(projectDir, dbPassword, superadminPassword string) error { - secretsDir := filepath.Join(projectDir, "secrets") +func createInstance(instanceDir, dbPassword, superadminPassword string) error { + secretsDir := filepath.Join(instanceDir, constants.SecretsDirName) if _, err := os.Stat(secretsDir); os.IsNotExist(err) { return fmt.Errorf("secrets directory does not exist: %s (run 'setup' first)", secretsDir) } - logger.Info("Creating instance: %s", filepath.Base(projectDir)) + logger.Info("Creating instance: %s", filepath.Base(instanceDir)) logger.Debug("Securing secrets directory: %s", secretsDir) if err := secureSecretsDirectory(secretsDir); err != nil { return fmt.Errorf("securing secrets directory: %w", err) } - pgPasswordPath := filepath.Join(secretsDir, pgPasswordFile) + pgPasswordPath := filepath.Join(secretsDir, constants.PgPasswordFile) logger.Debug("Writing PostgreSQL password to: %s", pgPasswordPath) - if err := os.WriteFile(pgPasswordPath, []byte(dbPassword), secretFilePerm); err != nil { + if err := os.WriteFile(pgPasswordPath, []byte(dbPassword), constants.SecretFilePerm); err != nil { return fmt.Errorf("writing postgres password: %w", err) } - superadminPath := filepath.Join(secretsDir, adminSecretsFile) + superadminPath := filepath.Join(secretsDir, constants.AdminSecretsFile) logger.Debug("Writing superadmin password to: %s", superadminPath) - if err := os.WriteFile(superadminPath, []byte(superadminPassword), secretFilePerm); err != nil { + if err := os.WriteFile(superadminPath, []byte(superadminPassword), constants.SecretFilePerm); err != nil { return fmt.Errorf("writing superadmin password: %w", err) } @@ -103,7 +98,7 @@ func createInstance(projectDir, dbPassword, superadminPassword string) error { // secureSecretsDirectory sets restrictive permissions on the secrets directory and all files within func secureSecretsDirectory(secretsDir string) error { - if err := os.Chmod(secretsDir, secretDirPerm); err != nil { + if err := os.Chmod(secretsDir, constants.SecretsDirPerm); err != nil { return fmt.Errorf("setting directory permissions: %w", err) } @@ -118,7 +113,7 @@ func secureSecretsDirectory(secretsDir string) error { } filePath := filepath.Join(secretsDir, entry.Name()) - if err := os.Chmod(filePath, secretFilePerm); err != nil { + if err := os.Chmod(filePath, constants.SecretFilePerm); err != nil { return fmt.Errorf("setting permissions for %s: %w", entry.Name(), err) } } diff --git a/internal/instance/create/create_test.go b/internal/instance/create/create_test.go index 9eb9c85..9b4c171 100644 --- a/internal/instance/create/create_test.go +++ b/internal/instance/create/create_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/OpenSlides/openslides-cli/internal/constants" ) func TestSecureSecretsDirectory(t *testing.T) { @@ -18,17 +20,17 @@ func TestSecureSecretsDirectory(t *testing.T) { } }) - // Create a secrets directory with some files - secretsDir := filepath.Join(tmpDir, "secrets") - if err := os.MkdirAll(secretsDir, 0755); err != nil { + // Create a secrets directory with some files (wrong perm) + secretsDir := filepath.Join(tmpDir, constants.SecretsDirName) + if err := os.MkdirAll(secretsDir, constants.InstanceDirPerm); err != nil { t.Fatalf("Failed to create secrets dir: %v", err) } - // Create test secret files with open permissions + // Create test secret files with open permissions (to test they get secured) testFiles := []string{"secret1", "secret2", "secret3"} for _, filename := range testFiles { path := filepath.Join(secretsDir, filename) - if err := os.WriteFile(path, []byte("test"), 0644); err != nil { + if err := os.WriteFile(path, []byte("test"), constants.StackFilePerm); err != nil { t.Fatalf("Failed to create test file %s: %v", filename, err) } } @@ -39,19 +41,17 @@ func TestSecureSecretsDirectory(t *testing.T) { t.Fatalf("secureSecretsDirectory failed: %v", err) } - // Verify directory permissions (700) + // Verify directory permissions (0700) dirInfo, err := os.Stat(secretsDir) if err != nil { t.Fatalf("Failed to stat secrets directory: %v", err) } - expectedDirPerms := os.FileMode(secretDirPerm) - if dirInfo.Mode().Perm() != expectedDirPerms { - t.Errorf("Directory permissions = %v, want %v", dirInfo.Mode().Perm(), expectedDirPerms) + if dirInfo.Mode().Perm() != constants.SecretsDirPerm { + t.Errorf("Directory permissions = %v, want %v", dirInfo.Mode().Perm(), constants.SecretsDirPerm) } - // Verify all file permissions (600) - expectedFilePerms := os.FileMode(secretFilePerm) + // Verify all file permissions (0600) for _, filename := range testFiles { path := filepath.Join(secretsDir, filename) fileInfo, err := os.Stat(path) @@ -59,8 +59,8 @@ func TestSecureSecretsDirectory(t *testing.T) { t.Fatalf("Failed to stat file %s: %v", filename, err) } - if fileInfo.Mode().Perm() != expectedFilePerms { - t.Errorf("File %s permissions = %v, want %v", filename, fileInfo.Mode().Perm(), expectedFilePerms) + if fileInfo.Mode().Perm() != constants.SecretFilePerm { + t.Errorf("File %s permissions = %v, want %v", filename, fileInfo.Mode().Perm(), constants.SecretFilePerm) } } } @@ -77,20 +77,20 @@ func TestSecureSecretsDirectory_SkipsSubdirectories(t *testing.T) { }) // Create secrets directory - secretsDir := filepath.Join(tmpDir, "secrets") - if err := os.MkdirAll(secretsDir, 0755); err != nil { + secretsDir := filepath.Join(tmpDir, constants.SecretsDirName) + if err := os.MkdirAll(secretsDir, constants.InstanceDirPerm); err != nil { t.Fatalf("Failed to create secrets dir: %v", err) } // Create a subdirectory within secrets subDir := filepath.Join(secretsDir, "subdir") - if err := os.MkdirAll(subDir, 0755); err != nil { + if err := os.MkdirAll(subDir, constants.InstanceDirPerm); err != nil { t.Fatalf("Failed to create subdirectory: %v", err) } // Create a file testFile := filepath.Join(secretsDir, "secret1") - if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + if err := os.WriteFile(testFile, []byte("test"), constants.StackFilePerm); err != nil { t.Fatalf("Failed to create test file: %v", err) } @@ -106,9 +106,9 @@ func TestSecureSecretsDirectory_SkipsSubdirectories(t *testing.T) { t.Fatalf("Failed to stat subdirectory: %v", err) } - // Should still have original 0755 permissions - if subDirInfo.Mode().Perm() == os.FileMode(secretFilePerm) { - t.Error("Subdirectory permissions should not be changed to secretFilePerm") + // Should still have original permissions (not changed to secret file permissions) + if subDirInfo.Mode().Perm() == constants.SecretFilePerm { + t.Error("Subdirectory permissions should not be changed to SecretFilePerm") } // Verify file permissions WERE changed @@ -117,9 +117,8 @@ func TestSecureSecretsDirectory_SkipsSubdirectories(t *testing.T) { t.Fatalf("Failed to stat file: %v", err) } - expectedPerms := os.FileMode(secretFilePerm) - if fileInfo.Mode().Perm() != expectedPerms { - t.Errorf("File permissions = %v, want %v", fileInfo.Mode().Perm(), expectedPerms) + if fileInfo.Mode().Perm() != constants.SecretFilePerm { + t.Errorf("File permissions = %v, want %v", fileInfo.Mode().Perm(), constants.SecretFilePerm) } } @@ -135,21 +134,21 @@ func TestCreateInstance(t *testing.T) { }) // Create secrets directory - secretsDir := filepath.Join(tmpDir, "secrets") - if err := os.MkdirAll(secretsDir, 0755); err != nil { + secretsDir := filepath.Join(tmpDir, constants.SecretsDirName) + if err := os.MkdirAll(secretsDir, constants.SecretsDirPerm); err != nil { t.Fatalf("Failed to create secrets dir: %v", err) } // Create some existing secret files (simulating 'setup' output) existingSecrets := map[string]string{ - "postgres_password": "old-db-password", - "superadmin": "old-admin-password", - "internal_auth_password": "some-auth-key", + constants.PgPasswordFile: "old-db-password", + constants.AdminSecretsFile: "old-admin-password", + constants.InternalAuthPassword: "some-auth-key", } for filename, content := range existingSecrets { path := filepath.Join(secretsDir, filename) - if err := os.WriteFile(path, []byte(content), 0644); err != nil { + if err := os.WriteFile(path, []byte(content), constants.SecretFilePerm); err != nil { t.Fatalf("Failed to create existing secret %s: %v", filename, err) } } @@ -164,7 +163,7 @@ func TestCreateInstance(t *testing.T) { } // Verify postgres_password was overwritten - pgContent, err := os.ReadFile(filepath.Join(secretsDir, pgPasswordFile)) + pgContent, err := os.ReadFile(filepath.Join(secretsDir, constants.PgPasswordFile)) if err != nil { t.Fatalf("Failed to read postgres_password: %v", err) } @@ -173,7 +172,7 @@ func TestCreateInstance(t *testing.T) { } // Verify superadmin was overwritten - adminContent, err := os.ReadFile(filepath.Join(secretsDir, adminSecretsFile)) + adminContent, err := os.ReadFile(filepath.Join(secretsDir, constants.AdminSecretsFile)) if err != nil { t.Fatalf("Failed to read superadmin: %v", err) } @@ -182,21 +181,20 @@ func TestCreateInstance(t *testing.T) { } // Verify other secrets were not touched - authContent, err := os.ReadFile(filepath.Join(secretsDir, "internal_auth_password")) + authContent, err := os.ReadFile(filepath.Join(secretsDir, constants.InternalAuthPassword)) if err != nil { t.Fatalf("Failed to read internal_auth_password: %v", err) } - if string(authContent) != existingSecrets["internal_auth_password"] { + if string(authContent) != existingSecrets[constants.InternalAuthPassword] { t.Errorf("internal_auth_password was unexpectedly changed") } - // Verify all files have secretFilePerm permissions + // Verify all files have SecretFilePerm permissions entries, err := os.ReadDir(secretsDir) if err != nil { t.Fatalf("Failed to read secrets directory: %v", err) } - expectedPerms := os.FileMode(secretFilePerm) for _, entry := range entries { if entry.IsDir() { continue @@ -208,20 +206,19 @@ func TestCreateInstance(t *testing.T) { t.Fatalf("Failed to stat %s: %v", entry.Name(), err) } - if fileInfo.Mode().Perm() != expectedPerms { - t.Errorf("File %s permissions = %v, want %v", entry.Name(), fileInfo.Mode().Perm(), expectedPerms) + if fileInfo.Mode().Perm() != constants.SecretFilePerm { + t.Errorf("File %s permissions = %v, want %v", entry.Name(), fileInfo.Mode().Perm(), constants.SecretFilePerm) } } - // Verify directory has secretDirPerm permissions + // Verify directory has SecretsDirPerm permissions dirInfo, err := os.Stat(secretsDir) if err != nil { t.Fatalf("Failed to stat secrets directory: %v", err) } - expectedDirPerms := os.FileMode(secretDirPerm) - if dirInfo.Mode().Perm() != expectedDirPerms { - t.Errorf("Directory permissions = %v, want %v", dirInfo.Mode().Perm(), expectedDirPerms) + if dirInfo.Mode().Perm() != constants.SecretsDirPerm { + t.Errorf("Directory permissions = %v, want %v", dirInfo.Mode().Perm(), constants.SecretsDirPerm) } } diff --git a/internal/instance/remove/remove.go b/internal/instance/remove/remove.go index 887de70..dada244 100644 --- a/internal/instance/remove/remove.go +++ b/internal/instance/remove/remove.go @@ -21,7 +21,7 @@ Examples: func Cmd() *cobra.Command { cmd := &cobra.Command{ - Use: "remove ", + Use: "remove ", Short: RemoveHelp, Long: RemoveHelp + "\n\n" + RemoveHelpExtra, Args: cobra.ExactArgs(1), @@ -31,10 +31,10 @@ func Cmd() *cobra.Command { cmd.RunE = func(cmd *cobra.Command, args []string) error { logger.Info("=== K8S REMOVE INSTANCE ===") - projectDir := args[0] - logger.Debug("Project directory: %s", projectDir) + instanceDir := args[0] + logger.Debug("Instance directory: %s", instanceDir) - if err := removeInstance(projectDir, *force); err != nil { + if err := removeInstance(instanceDir, *force); err != nil { return fmt.Errorf("removing instance: %w", err) } @@ -45,22 +45,22 @@ func Cmd() *cobra.Command { return cmd } -// removeInstance removes the entire project directory -func removeInstance(projectDir string, force bool) error { - info, err := os.Stat(projectDir) +// removeInstance removes the entire instance directory +func removeInstance(instanceDir string, force bool) error { + info, err := os.Stat(instanceDir) if err != nil { if os.IsNotExist(err) { - return fmt.Errorf("%s does not exist", projectDir) + return fmt.Errorf("%s does not exist", instanceDir) } return fmt.Errorf("checking directory: %w", err) } if !info.IsDir() { - return fmt.Errorf("%s is not a directory", projectDir) + return fmt.Errorf("%s is not a directory", instanceDir) } if !force { - logger.Warn("This will permanently delete: %s", projectDir) + logger.Warn("This will permanently delete: %s", instanceDir) logger.Warn("All configuration files, secrets, and data will be lost!") fmt.Print("Are you sure you want to continue? [y/N]: ") @@ -73,9 +73,9 @@ func removeInstance(projectDir string, force bool) error { } } - logger.Info("Removing instance directory: %s", projectDir) + logger.Info("Removing instance directory: %s", instanceDir) - if err := os.RemoveAll(projectDir); err != nil { + if err := os.RemoveAll(instanceDir); err != nil { return fmt.Errorf("removing directory: %w", err) } diff --git a/internal/instance/remove/remove_test.go b/internal/instance/remove/remove_test.go index 9e1f8ce..b2bcd43 100644 --- a/internal/instance/remove/remove_test.go +++ b/internal/instance/remove/remove_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/OpenSlides/openslides-cli/internal/constants" ) func TestRemoveInstance_DirectoryExists(t *testing.T) { @@ -13,18 +15,18 @@ func TestRemoveInstance_DirectoryExists(t *testing.T) { t.Fatalf("Failed to create temp dir: %v", err) } - projectDir := filepath.Join(tmpDir, "test-instance") - if err := os.MkdirAll(projectDir, 0755); err != nil { - t.Fatalf("Failed to create project dir: %v", err) + instanceDir := filepath.Join(tmpDir, "test-instance") + if err := os.MkdirAll(instanceDir, constants.InstanceDirPerm); err != nil { + t.Fatalf("Failed to create instance dir: %v", err) } - secretsDir := filepath.Join(projectDir, "secrets") - if err := os.MkdirAll(secretsDir, 0755); err != nil { + secretsDir := filepath.Join(instanceDir, constants.SecretsDirName) + if err := os.MkdirAll(secretsDir, constants.SecretsDirPerm); err != nil { t.Fatalf("Failed to create secrets dir: %v", err) } testFile := filepath.Join(secretsDir, "test_secret") - if err := os.WriteFile(testFile, []byte("secret"), 0600); err != nil { + if err := os.WriteFile(testFile, []byte("secret"), constants.SecretFilePerm); err != nil { t.Fatalf("Failed to create test file: %v", err) } @@ -34,13 +36,13 @@ func TestRemoveInstance_DirectoryExists(t *testing.T) { } }) - err = removeInstance(projectDir, true) + err = removeInstance(instanceDir, true) if err != nil { t.Fatalf("removeInstance failed: %v", err) } - if _, err := os.Stat(projectDir); !os.IsNotExist(err) { - t.Errorf("Project directory still exists after removal") + if _, err := os.Stat(instanceDir); !os.IsNotExist(err) { + t.Errorf("Instance directory still exists after removal") } } @@ -80,7 +82,7 @@ func TestRemoveInstance_NotADirectory(t *testing.T) { }) testFile := filepath.Join(tmpDir, "test-file") - if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + if err := os.WriteFile(testFile, []byte("test"), constants.StackFilePerm); err != nil { t.Fatalf("Failed to create test file: %v", err) } @@ -101,31 +103,33 @@ func TestRemoveInstance_RemovesNestedStructure(t *testing.T) { t.Fatalf("Failed to create temp dir: %v", err) } - projectDir := filepath.Join(tmpDir, "complex-instance") + instanceDir := filepath.Join(tmpDir, "complex-instance") - dirs := []string{ - filepath.Join(projectDir, "secrets"), - filepath.Join(projectDir, "config"), - filepath.Join(projectDir, "data", "postgres"), - filepath.Join(projectDir, "data", "redis"), + // Create directories with appropriate permissions + dirs := map[string]os.FileMode{ + filepath.Join(instanceDir, constants.SecretsDirName): constants.SecretsDirPerm, + filepath.Join(instanceDir, constants.StackDirName): constants.StackDirPerm, + filepath.Join(instanceDir, "data", "postgres"): constants.InstanceDirPerm, + filepath.Join(instanceDir, "data", "redis"): constants.InstanceDirPerm, } - for _, dir := range dirs { - if err := os.MkdirAll(dir, 0755); err != nil { + for dir, perm := range dirs { + if err := os.MkdirAll(dir, perm); err != nil { t.Fatalf("Failed to create dir %s: %v", dir, err) } } - files := []string{ - filepath.Join(projectDir, "secrets", "postgres_password"), - filepath.Join(projectDir, "secrets", "superadmin"), - filepath.Join(projectDir, "config", "docker-compose.yml"), - filepath.Join(projectDir, "data", "postgres", "pg_data.db"), - filepath.Join(projectDir, "data", "redis", "dump.rdb"), + // Create files with appropriate permissions + files := map[string]os.FileMode{ + filepath.Join(instanceDir, constants.SecretsDirName, constants.PgPasswordFile): constants.SecretFilePerm, + filepath.Join(instanceDir, constants.SecretsDirName, constants.AdminSecretsFile): constants.SecretFilePerm, + filepath.Join(instanceDir, constants.StackDirName, "deployment.yaml"): constants.StackFilePerm, + filepath.Join(instanceDir, "data", "postgres", "pg_data.db"): constants.StackFilePerm, + filepath.Join(instanceDir, "data", "redis", "dump.rdb"): constants.StackFilePerm, } - for _, file := range files { - if err := os.WriteFile(file, []byte("test data"), 0644); err != nil { + for file, perm := range files { + if err := os.WriteFile(file, []byte("test data"), perm); err != nil { t.Fatalf("Failed to create file %s: %v", file, err) } } @@ -136,13 +140,13 @@ func TestRemoveInstance_RemovesNestedStructure(t *testing.T) { } }) - err = removeInstance(projectDir, true) + err = removeInstance(instanceDir, true) if err != nil { t.Fatalf("removeInstance failed: %v", err) } - if _, err := os.Stat(projectDir); !os.IsNotExist(err) { - t.Error("Project directory still exists after removal") + if _, err := os.Stat(instanceDir); !os.IsNotExist(err) { + t.Error("Instance directory still exists after removal") } if _, err := os.Stat(tmpDir); os.IsNotExist(err) { @@ -156,9 +160,9 @@ func TestRemoveInstance_WithForceFlag(t *testing.T) { t.Fatalf("Failed to create temp dir: %v", err) } - projectDir := filepath.Join(tmpDir, "test-instance") - if err := os.MkdirAll(projectDir, 0755); err != nil { - t.Fatalf("Failed to create project dir: %v", err) + instanceDir := filepath.Join(tmpDir, "test-instance") + if err := os.MkdirAll(instanceDir, constants.InstanceDirPerm); err != nil { + t.Fatalf("Failed to create instance dir: %v", err) } t.Cleanup(func() { @@ -167,13 +171,13 @@ func TestRemoveInstance_WithForceFlag(t *testing.T) { } }) - err = removeInstance(projectDir, true) + err = removeInstance(instanceDir, true) if err != nil { t.Fatalf("removeInstance with force=true failed: %v", err) } - if _, err := os.Stat(projectDir); !os.IsNotExist(err) { - t.Error("Project directory still exists after removal") + if _, err := os.Stat(instanceDir); !os.IsNotExist(err) { + t.Error("Instance directory still exists after removal") } } @@ -183,9 +187,9 @@ func TestRemoveInstance_EmptyDirectory(t *testing.T) { t.Fatalf("Failed to create temp dir: %v", err) } - projectDir := filepath.Join(tmpDir, "empty-instance") - if err := os.MkdirAll(projectDir, 0755); err != nil { - t.Fatalf("Failed to create project dir: %v", err) + instanceDir := filepath.Join(tmpDir, "empty-instance") + if err := os.MkdirAll(instanceDir, constants.InstanceDirPerm); err != nil { + t.Fatalf("Failed to create instance dir: %v", err) } t.Cleanup(func() { @@ -194,12 +198,12 @@ func TestRemoveInstance_EmptyDirectory(t *testing.T) { } }) - err = removeInstance(projectDir, true) + err = removeInstance(instanceDir, true) if err != nil { t.Fatalf("removeInstance failed on empty directory: %v", err) } - if _, err := os.Stat(projectDir); !os.IsNotExist(err) { + if _, err := os.Stat(instanceDir); !os.IsNotExist(err) { t.Error("Empty directory still exists after removal") } } @@ -210,17 +214,17 @@ func TestRemoveInstance_WithSymlinks(t *testing.T) { t.Fatalf("Failed to create temp dir: %v", err) } - projectDir := filepath.Join(tmpDir, "test-instance") - if err := os.MkdirAll(projectDir, 0755); err != nil { - t.Fatalf("Failed to create project dir: %v", err) + instanceDir := filepath.Join(tmpDir, "test-instance") + if err := os.MkdirAll(instanceDir, constants.InstanceDirPerm); err != nil { + t.Fatalf("Failed to create instance dir: %v", err) } targetFile := filepath.Join(tmpDir, "target.txt") - if err := os.WriteFile(targetFile, []byte("target"), 0644); err != nil { + if err := os.WriteFile(targetFile, []byte("target"), constants.StackFilePerm); err != nil { t.Fatalf("Failed to create target file: %v", err) } - symlinkPath := filepath.Join(projectDir, "link") + symlinkPath := filepath.Join(instanceDir, "link") if err := os.Symlink(targetFile, symlinkPath); err != nil { t.Fatalf("Failed to create symlink: %v", err) } @@ -231,13 +235,13 @@ func TestRemoveInstance_WithSymlinks(t *testing.T) { } }) - err = removeInstance(projectDir, true) + err = removeInstance(instanceDir, true) if err != nil { t.Fatalf("removeInstance failed: %v", err) } - if _, err := os.Stat(projectDir); !os.IsNotExist(err) { - t.Error("Project directory still exists after removal") + if _, err := os.Stat(instanceDir); !os.IsNotExist(err) { + t.Error("Instance directory still exists after removal") } if _, err := os.Stat(targetFile); os.IsNotExist(err) { diff --git a/internal/instance/setup/setup.go b/internal/instance/setup/setup.go index d390286..662b3f5 100644 --- a/internal/instance/setup/setup.go +++ b/internal/instance/setup/setup.go @@ -11,12 +11,12 @@ import ( "encoding/pem" "fmt" "io" - "io/fs" "math/big" "os" "path/filepath" "time" + "github.com/OpenSlides/openslides-cli/internal/constants" "github.com/OpenSlides/openslides-cli/internal/instance/config" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/OpenSlides/openslides-cli/internal/utils" @@ -39,14 +39,6 @@ Examples: osmanage setup ./my.instance.dir.org --force osmanage setup ./my.instance.dir.org --template ./custom --config ./config.yaml osmanage setup ./my.instance.dir.org --config ./base.yaml --config ./override.yaml` - - DefaultSuperadminPasswordLength = 20 - DefaultPostgresPasswordLength = 40 - SecretsDirName = "secrets" - - subDirPerms fs.FileMode = 0770 - certCertName = "cert_crt" - certKeyName = "cert_key" ) type SecretSpec struct { @@ -55,16 +47,16 @@ type SecretSpec struct { } var defaultSecrets = []SecretSpec{ - {"auth_token_key", randomSecret}, - {"auth_cookie_key", randomSecret}, - {"internal_auth_password", randomSecret}, - {"postgres_password", func() ([]byte, error) { return randomString(DefaultPostgresPasswordLength) }}, - {"superadmin", func() ([]byte, error) { return randomString(DefaultSuperadminPasswordLength) }}, + {constants.AuthTokenKey, randomSecret}, + {constants.AuthCookieKey, randomSecret}, + {constants.InternalAuthPassword, randomSecret}, + {constants.PgPasswordFile, func() ([]byte, error) { return randomString(constants.DefaultPostgresPasswordLength) }}, + {constants.AdminSecretsFile, func() ([]byte, error) { return randomString(constants.DefaultSuperadminPasswordLength) }}, } func Cmd() *cobra.Command { cmd := &cobra.Command{ - Use: "setup ", + Use: "setup ", Short: SetupHelp, Long: SetupHelp + "\n\n" + SetupHelpExtra, Args: cobra.ExactArgs(1), @@ -89,22 +81,22 @@ func Cmd() *cobra.Command { } // Create secrets directory - secrDir := filepath.Join(baseDir, SecretsDirName) - logger.Debug("Creating secrets directory: %s", secrDir) - if err := os.MkdirAll(secrDir, subDirPerms); err != nil { + secretsDir := filepath.Join(baseDir, constants.SecretsDirName) + logger.Debug("Creating secrets directory: %s", secretsDir) + if err := os.MkdirAll(secretsDir, constants.SecretsDirPerm); err != nil { return fmt.Errorf("creating secrets directory: %w", err) } // Create secrets logger.Info("Creating secrets...") - if err := createSecrets(secrDir, *force, defaultSecrets); err != nil { + if err := createSecrets(secretsDir, *force, defaultSecrets); err != nil { return fmt.Errorf("creating secrets: %w", err) } // Create certificates if HTTPS is enabled if enableLocalHTTPS, ok := cfg["enableLocalHTTPS"].(bool); ok && enableLocalHTTPS { logger.Info("Creating SSL certificates...") - if err := createCerts(secrDir, *force); err != nil { + if err := createCerts(secretsDir, *force); err != nil { return fmt.Errorf("creating certificates: %w", err) } } @@ -130,7 +122,7 @@ func createSecrets(dir string, force bool, secrets []SecretSpec) error { if err != nil { return fmt.Errorf("generating secret %q: %w", spec.Name, err) } - if err := utils.CreateFile(dir, force, spec.Name, data); err != nil { + if err := utils.CreateFile(dir, force, spec.Name, data, constants.SecretFilePerm); err != nil { return fmt.Errorf("creating secret file %q: %w", spec.Name, err) } } @@ -211,7 +203,7 @@ func createCerts(dir string, force bool) error { if err := pem.Encode(buf1, &pem.Block{Type: "CERTIFICATE", Bytes: certData}); err != nil { return fmt.Errorf("encoding certificate: %w", err) } - if err := utils.CreateFile(dir, force, certCertName, buf1.Bytes()); err != nil { + if err := utils.CreateFile(dir, force, constants.CertCertName, buf1.Bytes(), constants.SecretFilePerm); err != nil { return fmt.Errorf("creating certificate file: %w", err) } @@ -224,7 +216,7 @@ func createCerts(dir string, force bool) error { if err := pem.Encode(buf2, &pem.Block{Type: "PRIVATE KEY", Bytes: keyData}); err != nil { return fmt.Errorf("encoding key: %w", err) } - if err := utils.CreateFile(dir, force, certKeyName, buf2.Bytes()); err != nil { + if err := utils.CreateFile(dir, force, constants.CertKeyName, buf2.Bytes(), constants.SecretFilePerm); err != nil { return fmt.Errorf("creating key file: %w", err) } diff --git a/internal/instance/setup/setup_test.go b/internal/instance/setup/setup_test.go index 7b836bf..19ed6ab 100644 --- a/internal/instance/setup/setup_test.go +++ b/internal/instance/setup/setup_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/OpenSlides/openslides-cli/internal/constants" ) func TestRandomSecret(t *testing.T) { @@ -144,14 +146,23 @@ func TestCreateSecrets(t *testing.T) { if string(data2) != "secret2" { t.Errorf("Expected 'secret2', got %s", string(data2)) } + + // Verify permissions + info1, err := os.Stat(filepath.Join(tmpdir, "test_secret1")) + if err != nil { + t.Fatalf("failed to stat secret file: %v", err) + } + if info1.Mode().Perm() != constants.SecretFilePerm { + t.Errorf("Secret file permissions = %v, want %v", info1.Mode().Perm(), constants.SecretFilePerm) + } } func TestCreateSecrets_NoOverwrite(t *testing.T) { tmpdir := t.TempDir() // Create initial secret - if err := os.WriteFile(filepath.Join(tmpdir, "existing"), []byte("original"), 0644); err != nil { - t.Fatalf("failed to write template: %v", err) + if err := os.WriteFile(filepath.Join(tmpdir, "existing"), []byte("original"), constants.SecretFilePerm); err != nil { + t.Fatalf("failed to write initial secret: %v", err) } specs := []SecretSpec{ @@ -190,7 +201,8 @@ func TestCreateCerts(t *testing.T) { } // Check cert file - certData, err := os.ReadFile(filepath.Join(tmpdir, "cert_crt")) + certPath := filepath.Join(tmpdir, constants.CertCertName) + certData, err := os.ReadFile(certPath) if err != nil { t.Error("Expected cert_crt to be created") } @@ -198,24 +210,43 @@ func TestCreateCerts(t *testing.T) { t.Error("Expected PEM encoded certificate") } + // Verify cert permissions + certInfo, err := os.Stat(certPath) + if err != nil { + t.Fatalf("failed to stat cert file: %v", err) + } + if certInfo.Mode().Perm() != constants.SecretFilePerm { + t.Errorf("Cert file permissions = %v, want %v", certInfo.Mode().Perm(), constants.SecretFilePerm) + } + // Check key file - keyData, err := os.ReadFile(filepath.Join(tmpdir, "cert_key")) + keyPath := filepath.Join(tmpdir, constants.CertKeyName) + keyData, err := os.ReadFile(keyPath) if err != nil { t.Error("Expected cert_key to be created") } if !strings.Contains(string(keyData), "BEGIN PRIVATE KEY") { t.Error("Expected PEM encoded private key") } + + // Verify key permissions + keyInfo, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("failed to stat key file: %v", err) + } + if keyInfo.Mode().Perm() != constants.SecretFilePerm { + t.Errorf("Key file permissions = %v, want %v", keyInfo.Mode().Perm(), constants.SecretFilePerm) + } } func TestDefaultSecrets(t *testing.T) { - // Verify default secrets are defined + // Verify default secrets are defined with correct names from constants expectedSecrets := []string{ - "auth_token_key", - "auth_cookie_key", - "internal_auth_password", - "postgres_password", - "superadmin", + constants.AuthTokenKey, + constants.AuthCookieKey, + constants.InternalAuthPassword, + constants.PgPasswordFile, + constants.AdminSecretsFile, } if len(defaultSecrets) != len(expectedSecrets) { @@ -230,33 +261,33 @@ func TestDefaultSecrets(t *testing.T) { // Test that postgres_password generates proper string for _, spec := range defaultSecrets { - if spec.Name == "postgres_password" { + if spec.Name == constants.PgPasswordFile { pwd, err := spec.Generator() if err != nil { t.Errorf("postgres_password generator error = %v", err) } - if len(pwd) != DefaultPostgresPasswordLength { - t.Errorf("Expected length %d, got %d", DefaultPostgresPasswordLength, len(pwd)) + if len(pwd) != constants.DefaultPostgresPasswordLength { + t.Errorf("Expected length %d, got %d", constants.DefaultPostgresPasswordLength, len(pwd)) } } } // Test superadmin password generator for _, spec := range defaultSecrets { - if spec.Name == "superadmin" { + if spec.Name == constants.AdminSecretsFile { pwd, err := spec.Generator() if err != nil { t.Errorf("Superadmin generator error = %v", err) } - if len(pwd) != DefaultSuperadminPasswordLength { - t.Errorf("Expected length %d, got %d", DefaultSuperadminPasswordLength, len(pwd)) + if len(pwd) != constants.DefaultSuperadminPasswordLength { + t.Errorf("Expected length %d, got %d", constants.DefaultSuperadminPasswordLength, len(pwd)) } } } // Test that base64-encoded secrets still work as expected for _, spec := range defaultSecrets { - if spec.Name == "auth_token_key" || spec.Name == "auth_cookie_key" || spec.Name == "internal_auth_password" { + if spec.Name == constants.AuthTokenKey || spec.Name == constants.AuthCookieKey || spec.Name == constants.InternalAuthPassword { secret, err := spec.Generator() if err != nil { t.Errorf("%s generator error = %v", spec.Name, err) @@ -288,8 +319,8 @@ defaults: containerRegistry: registry.example.com tag: 4.2.21 ` - if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil { - t.Fatalf("failed to write template: %v", err) + if err := os.WriteFile(configFile, []byte(configContent), constants.StackFilePerm); err != nil { + t.Fatalf("failed to write config file: %v", err) } // Create a minimal template using camelCase config keys @@ -304,7 +335,7 @@ disablePostgres: {{ .disablePostgres }} registry: {{ .defaults.containerRegistry }} tag: {{ .defaults.tag }} ` - if err := os.WriteFile(templateFile, []byte(templateContent), 0644); err != nil { + if err := os.WriteFile(templateFile, []byte(templateContent), constants.StackFilePerm); err != nil { t.Fatalf("failed to write template: %v", err) } @@ -312,12 +343,21 @@ tag: {{ .defaults.tag }} outDir := filepath.Join(tmpdir, "output") t.Run("full setup flow", func(t *testing.T) { - // 1. Create secrets directory - secretsDir := filepath.Join(outDir, SecretsDirName) - if err := os.MkdirAll(secretsDir, 0755); err != nil { + // 1. Create secrets directory with correct permissions + secretsDir := filepath.Join(outDir, constants.SecretsDirName) + if err := os.MkdirAll(secretsDir, constants.SecretsDirPerm); err != nil { t.Fatal(err) } + // Verify secrets directory permissions + dirInfo, err := os.Stat(secretsDir) + if err != nil { + t.Fatalf("failed to stat secrets directory: %v", err) + } + if dirInfo.Mode().Perm() != constants.SecretsDirPerm { + t.Errorf("Secrets directory permissions = %v, want %v", dirInfo.Mode().Perm(), constants.SecretsDirPerm) + } + // 2. Create secrets if err := createSecrets(secretsDir, false, defaultSecrets); err != nil { t.Errorf("createSecrets() error = %v", err) @@ -332,21 +372,17 @@ tag: {{ .defaults.tag }} } // 4. Verify superadmin password length - superadminPath := filepath.Join(secretsDir, "superadmin") + superadminPath := filepath.Join(secretsDir, constants.AdminSecretsFile) pwd, _ := os.ReadFile(superadminPath) - if len(pwd) != DefaultSuperadminPasswordLength { - t.Errorf("Expected superadmin password length %d, got %d", DefaultSuperadminPasswordLength, len(pwd)) + if len(pwd) != constants.DefaultSuperadminPasswordLength { + t.Errorf("Expected superadmin password length %d, got %d", constants.DefaultSuperadminPasswordLength, len(pwd)) } // 5. Check postgres_password has correct length (not base64) - postgresPath := filepath.Join(secretsDir, "postgres_password") + postgresPath := filepath.Join(secretsDir, constants.PgPasswordFile) postgresPwd, _ := os.ReadFile(postgresPath) - if len(postgresPwd) != DefaultPostgresPasswordLength { - t.Errorf("Expected postgres password length %d, got %d", DefaultPostgresPasswordLength, len(postgresPwd)) - } - // Verify it's not base64 encoded (shouldn't end with =) - if postgresPwd[len(postgresPwd)-1] == '=' { - t.Error("Postgres password should not be base64 encoded") + if len(postgresPwd) != constants.DefaultPostgresPasswordLength { + t.Errorf("Expected postgres password length %d, got %d", constants.DefaultPostgresPasswordLength, len(postgresPwd)) } }) @@ -390,16 +426,16 @@ defaults: containerRegistry: registry.example.com tag: 4.2.21 ` - if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil { - t.Fatalf("failed to write template: %v", err) + if err := os.WriteFile(configFile, []byte(configContent), constants.StackFilePerm); err != nil { + t.Fatalf("failed to write config file: %v", err) } outDir := filepath.Join(tmpdir, "output") - secretsDir := filepath.Join(outDir, SecretsDirName) + secretsDir := filepath.Join(outDir, constants.SecretsDirName) t.Run("creates HTTPS certificates when enabled", func(t *testing.T) { - // Create secrets directory - if err := os.MkdirAll(secretsDir, 0755); err != nil { + // Create secrets directory with correct permissions + if err := os.MkdirAll(secretsDir, constants.SecretsDirPerm); err != nil { t.Fatal(err) } @@ -414,8 +450,8 @@ defaults: } // Verify certificates exist - certPath := filepath.Join(secretsDir, "cert_crt") - keyPath := filepath.Join(secretsDir, "cert_key") + certPath := filepath.Join(secretsDir, constants.CertCertName) + keyPath := filepath.Join(secretsDir, constants.CertKeyName) if _, err := os.Stat(certPath); err != nil { t.Error("Expected cert_crt to be created when enableLocalHTTPS is true") diff --git a/internal/k8s/actions/apply.go b/internal/k8s/actions/apply.go index 89d57d2..95969d0 100644 --- a/internal/k8s/actions/apply.go +++ b/internal/k8s/actions/apply.go @@ -15,6 +15,14 @@ import ( "k8s.io/apimachinery/pkg/util/yaml" ) +const ( + // fieldManager identifies this client in Server-Side Apply operations + fieldManager string = "osmanage" + + // forceConflicts takes ownership of fields from other managers when conflicts occur + forceConflicts bool = true +) + // applyManifest applies a single YAML manifest file using RESTMapper func applyManifest(ctx context.Context, k8sClient *client.Client, manifestPath string) (string, error) { logger.Debug("Applying manifest: %s", manifestPath) @@ -62,8 +70,8 @@ func applyManifest(ctx context.Context, k8sClient *client.Client, manifestPath s obj.GetName(), &obj, metav1.ApplyOptions{ - FieldManager: "osmanage", - Force: true, + FieldManager: fieldManager, + Force: forceConflicts, }, ) } else { @@ -73,8 +81,8 @@ func applyManifest(ctx context.Context, k8sClient *client.Client, manifestPath s obj.GetName(), &obj, metav1.ApplyOptions{ - FieldManager: "osmanage", - Force: true, + FieldManager: fieldManager, + Force: forceConflicts, }, ) } diff --git a/internal/k8s/actions/health.go b/internal/k8s/actions/health.go index 86c3a73..0d5303d 100644 --- a/internal/k8s/actions/health.go +++ b/internal/k8s/actions/health.go @@ -3,8 +3,8 @@ package actions import ( "context" "fmt" - "time" + "github.com/OpenSlides/openslides-cli/internal/constants" "github.com/OpenSlides/openslides-cli/internal/k8s/client" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/spf13/cobra" @@ -29,12 +29,12 @@ func HealthCmd() *cobra.Command { kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") wait := cmd.Flags().Bool("wait", false, "Wait for instance to become healthy") - timeout := cmd.Flags().Duration("timeout", 3*time.Minute, "Timeout for wait operation") + timeout := cmd.Flags().Duration("timeout", constants.DefaultInstanceTimeout, "Timeout for instance health check") cmd.RunE = func(cmd *cobra.Command, args []string) error { logger.Info("=== K8S HEALTH CHECK ===") - projectDir := args[0] - namespace := extractNamespace(projectDir) + instanceDir := args[0] + namespace := extractNamespace(instanceDir) logger.Debug("Namespace: %s", namespace) k8sClient, err := client.New(*kubeconfig) @@ -45,7 +45,7 @@ func HealthCmd() *cobra.Command { ctx := context.Background() if *wait { - return waitForHealthy(ctx, k8sClient, namespace, *timeout) + return waitForInstanceHealthy(ctx, k8sClient, namespace, *timeout) } return checkHealth(ctx, k8sClient, namespace) diff --git a/internal/k8s/actions/health_check.go b/internal/k8s/actions/health_check.go index 922bafd..1035f48 100644 --- a/internal/k8s/actions/health_check.go +++ b/internal/k8s/actions/health_check.go @@ -15,8 +15,10 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// Width of the progress bar for wait functions -const progressBarWidth = 40 +const ( + // Width of the progress bar for wait functions + progressBarWidth int = 40 +) // HealthStatus represents the health status of an instance type HealthStatus struct { @@ -97,11 +99,11 @@ func checkHealth(ctx context.Context, k8sClient *client.Client, namespace string return nil } -// waitForHealthy waits for instance to become healthy -func waitForHealthy(ctx context.Context, k8sClient *client.Client, namespace string, timeout time.Duration) error { +// waitForInstanceHealthy waits for instance to become healthy +func waitForInstanceHealthy(ctx context.Context, k8sClient *client.Client, namespace string, timeout time.Duration) error { logger.Info("Waiting for instance to become healthy (timeout: %v)", timeout) - ticker := time.NewTicker(5 * time.Second) + ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() timeoutCtx, cancel := context.WithTimeout(ctx, timeout) @@ -125,12 +127,16 @@ func waitForHealthy(ctx context.Context, k8sClient *client.Client, namespace str } if bar != nil { - bar.Set(status.Ready) + if err := bar.Set(status.Ready); err != nil { + return fmt.Errorf("setting progress bar: %w", err) + } } if status.Healthy { if bar != nil { - bar.Finish() + if err := bar.Finish(); err != nil { + return fmt.Errorf("finishing progress bar: %w", err) + } } logger.Info("Instance is healthy: %d/%d pods ready", status.Ready, status.Total) return nil @@ -138,7 +144,9 @@ func waitForHealthy(ctx context.Context, k8sClient *client.Client, namespace str case <-timeoutCtx.Done(): if bar != nil { - bar.Finish() + if err := bar.Finish(); err != nil { + return fmt.Errorf("finishing progress bar: %w", err) + } } logger.Warn("Timeout reached. Current status:") if lastStatus != nil { @@ -264,3 +272,29 @@ func waitForDeploymentReady(ctx context.Context, k8sClient *client.Client, names } } } + +// waitForNamespaceDeletion waits for a namespace to be completely deleted +func waitForNamespaceDeletion(ctx context.Context, k8sClient *client.Client, namespace string, timeout time.Duration) error { + clientset := k8sClient.Clientset() + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + for { + select { + case <-ticker.C: + _, err := clientset.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) + if err != nil { + logger.Debug("Namespace %s successfully deleted", namespace) + return nil + } + logger.Debug("Namespace %s still terminating...", namespace) + + case <-timeoutCtx.Done(): + return fmt.Errorf("timeout waiting for namespace %s to be deleted", namespace) + } + } +} diff --git a/internal/k8s/actions/helpers.go b/internal/k8s/actions/helpers.go index 081a955..882441c 100644 --- a/internal/k8s/actions/helpers.go +++ b/internal/k8s/actions/helpers.go @@ -6,10 +6,10 @@ import ( "strings" ) -// extractNamespace gets the namespace from project directory path -// Example: "/real/path/to/my.project.dir.url" -> "myprojectdirurl" -func extractNamespace(projectDir string) string { - dirName := filepath.Base(projectDir) +// extractNamespace gets the namespace from instance directory path +// Example: "/real/path/to/my.instance.dir.url" -> "myinstancedirurl" +func extractNamespace(instanceDir string) string { + dirName := filepath.Base(instanceDir) namespace := strings.ReplaceAll(dirName, ".", "") return namespace } diff --git a/internal/k8s/actions/scale.go b/internal/k8s/actions/scale.go index 8fd1ea9..70c8abd 100644 --- a/internal/k8s/actions/scale.go +++ b/internal/k8s/actions/scale.go @@ -4,8 +4,8 @@ import ( "context" "fmt" "path/filepath" - "time" + "github.com/OpenSlides/openslides-cli/internal/constants" "github.com/OpenSlides/openslides-cli/internal/k8s/client" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/spf13/cobra" @@ -25,7 +25,7 @@ Examples: func ScaleCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "scale ", + Use: "scale ", Short: ScaleHelp, Long: ScaleHelp + "\n\n" + ScaleHelpExtra, Args: cobra.ExactArgs(1), @@ -34,7 +34,7 @@ func ScaleCmd() *cobra.Command { service := cmd.Flags().String("service", "", "Service deployment to scale (required)") kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") skipReadyCheck := cmd.Flags().Bool("skip-ready-check", false, "Skip waiting for deployment to become ready") - timeout := cmd.Flags().Duration("timeout", 3*time.Minute, "Timeout for ready check") + timeout := cmd.Flags().Duration("timeout", constants.DefaultDeploymentTimeout, "Timeout for deployment rollout check") _ = cmd.MarkFlagRequired("service") @@ -44,11 +44,11 @@ func ScaleCmd() *cobra.Command { } logger.Info("=== K8S SCALE SERVICE ===") - projectDir := args[0] - logger.Debug("Project directory: %s", projectDir) + instanceDir := args[0] + logger.Debug("Instance directory: %s", instanceDir) logger.Info("Service: %s", *service) - namespace := extractNamespace(projectDir) + namespace := extractNamespace(instanceDir) logger.Info("Namespace: %s", namespace) k8sClient, err := client.New(*kubeconfig) @@ -59,8 +59,8 @@ func ScaleCmd() *cobra.Command { ctx := context.Background() // Construct path to deployment file - deploymentFile := fmt.Sprintf("%s-deployment.yaml", *service) - deploymentPath := filepath.Join(projectDir, "stack", deploymentFile) + deploymentFile := fmt.Sprintf(constants.DeploymentFileTemplate, *service) + deploymentPath := filepath.Join(instanceDir, constants.StackDirName, deploymentFile) logger.Info("Applying deployment manifest: %s", deploymentPath) if _, err := applyManifest(ctx, k8sClient, deploymentPath); err != nil { @@ -73,7 +73,7 @@ func ScaleCmd() *cobra.Command { } logger.Info("Waiting for deployment to become ready...") - // Wait for the specific deployment (service name is deployment name) + // Wait for the specific deployment (OpenSlides service name is deployment name) if err := waitForDeploymentReady(ctx, k8sClient, namespace, *service, *timeout); err != nil { return fmt.Errorf("waiting for deployment ready: %w", err) } diff --git a/internal/k8s/actions/start.go b/internal/k8s/actions/start.go index 7d5eac0..e255a25 100644 --- a/internal/k8s/actions/start.go +++ b/internal/k8s/actions/start.go @@ -4,8 +4,8 @@ import ( "context" "fmt" "path/filepath" - "time" + "github.com/OpenSlides/openslides-cli/internal/constants" "github.com/OpenSlides/openslides-cli/internal/k8s/client" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/spf13/cobra" @@ -20,12 +20,12 @@ Examples: osmanage k8s start ./my.instance.dir.org --skip-ready-check osmanage k8s start ./my.instance.dir.org --kubeconfig ~/.kube/config --timeout 30s` - tlsCertSecretYAML = "secrets/tls-letsencrypt-secret.yaml" + namespaceYAML = "namespace.yaml" ) func StartCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "start ", + Use: "start ", Short: StartHelp, Long: StartHelp + "\n\n" + StartHelpExtra, Args: cobra.ExactArgs(1), @@ -33,12 +33,12 @@ func StartCmd() *cobra.Command { kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") skipReadyCheck := cmd.Flags().Bool("skip-ready-check", false, "Skip waiting for instance to become ready") - timeout := cmd.Flags().Duration("timeout", 3*time.Minute, "Timeout for ready check") + timeout := cmd.Flags().Duration("timeout", constants.DefaultInstanceTimeout, "Timeout for instance health check") cmd.RunE = func(cmd *cobra.Command, args []string) error { logger.Info("=== K8S START INSTANCE ===") - projectDir := args[0] - logger.Debug("Project directory: %s", projectDir) + instanceDir := args[0] + logger.Debug("Instance directory: %s", instanceDir) k8sClient, err := client.New(*kubeconfig) if err != nil { @@ -47,22 +47,22 @@ func StartCmd() *cobra.Command { ctx := context.Background() - namespacePath := filepath.Join(projectDir, "namespace.yaml") + namespacePath := filepath.Join(instanceDir, namespaceYAML) namespace, err := applyManifest(ctx, k8sClient, namespacePath) if err != nil { return fmt.Errorf("applying namespace: %w", err) } logger.Info("Applied namespace: %s", namespace) - tlsSecretPath := filepath.Join(projectDir, tlsCertSecretYAML) + tlsSecretPath := filepath.Join(instanceDir, constants.SecretsDirName, constants.TlsCertSecretYAML) if fileExists(tlsSecretPath) { - logger.Info("Found and applying %s", tlsCertSecretYAML) + logger.Info("Found and applying %s", tlsSecretPath) if _, err := applyManifest(ctx, k8sClient, tlsSecretPath); err != nil { return fmt.Errorf("applying TLS secret: %w", err) } } - stackDir := filepath.Join(projectDir, "stack") + stackDir := filepath.Join(instanceDir, constants.StackDirName) logger.Info("Applying stack manifests from: %s", stackDir) if err := applyDirectory(ctx, k8sClient, stackDir); err != nil { return fmt.Errorf("applying stack: %w", err) @@ -74,7 +74,7 @@ func StartCmd() *cobra.Command { } logger.Info("Waiting for instance to become ready...") - if err := waitForHealthy(ctx, k8sClient, namespace, *timeout); err != nil { + if err := waitForInstanceHealthy(ctx, k8sClient, namespace, *timeout); err != nil { return fmt.Errorf("waiting for ready: %w", err) } diff --git a/internal/k8s/actions/stop.go b/internal/k8s/actions/stop.go index 5d40dd7..cdafa27 100644 --- a/internal/k8s/actions/stop.go +++ b/internal/k8s/actions/stop.go @@ -7,6 +7,7 @@ import ( "path/filepath" "time" + "github.com/OpenSlides/openslides-cli/internal/constants" "github.com/OpenSlides/openslides-cli/internal/k8s/client" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/spf13/cobra" @@ -22,25 +23,24 @@ If a TLS certificate secret exists, it will be saved before deletion. Examples: osmanage k8s stop ./my.instance.dir.org --kubeconfig ~/.kube/config` - - tlsCertSecret = "tls-letsencrypt" ) func StopCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "stop ", + Use: "stop ", Short: StopHelp, Long: StopHelp + "\n\n" + StopHelpExtra, Args: cobra.ExactArgs(1), } kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") + timeout := cmd.Flags().Duration("timeout", constants.DefaultNamespaceTimeout, "timeout for namespace deletion") cmd.RunE = func(cmd *cobra.Command, args []string) error { - projectDir := args[0] - logger.Info("=== K8S STOP INSTANCE ===") - logger.Debug("Project directory: %s", projectDir) + instanceDir := args[0] + + logger.Debug("Instance directory: %s", instanceDir) k8sClient, err := client.New(*kubeconfig) if err != nil { @@ -49,13 +49,13 @@ func StopCmd() *cobra.Command { ctx := context.Background() - namespace := extractNamespace(projectDir) - if err := saveTLSSecret(ctx, k8sClient, namespace, projectDir); err != nil { + namespace := extractNamespace(instanceDir) + if err := saveTLSSecret(ctx, k8sClient, namespace, instanceDir); err != nil { logger.Warn("Failed to save TLS secret: %v", err) } logger.Info("Stopping instance: %s", namespace) - if err := deleteNamespace(ctx, k8sClient, namespace); err != nil { + if err := deleteNamespace(ctx, k8sClient, namespace, *timeout); err != nil { return fmt.Errorf("deleting namespace: %w", err) } @@ -67,29 +67,27 @@ func StopCmd() *cobra.Command { } // saveTLSSecret saves the TLS certificate secret to a YAML file if it exists -func saveTLSSecret(ctx context.Context, k8sClient *client.Client, namespace, projectDir string) error { +func saveTLSSecret(ctx context.Context, k8sClient *client.Client, namespace, instanceDir string) error { clientset := k8sClient.Clientset() - secret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, tlsCertSecret, metav1.GetOptions{}) + secret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, constants.TlsCertSecret, metav1.GetOptions{}) if err != nil { - logger.Debug("TLS secret %s not found in namespace %s", tlsCertSecret, namespace) + logger.Debug("TLS secret %s not found in namespace %s", constants.TlsCertSecret, namespace) return nil } - logger.Info("Found %s secret. Saving to %s", tlsCertSecret, tlsCertSecretYAML) - secretYAML, err := yaml.Marshal(secret) if err != nil { return fmt.Errorf("marshaling secret to YAML: %w", err) } - secretsDir := filepath.Join(projectDir, "secrets") - if err := os.MkdirAll(secretsDir, 0755); err != nil { + secretsDir := filepath.Join(instanceDir, constants.SecretsDirName) + if err := os.MkdirAll(secretsDir, constants.SecretsDirPerm); err != nil { return fmt.Errorf("creating secrets directory: %w", err) } - secretPath := filepath.Join(projectDir, tlsCertSecretYAML) - if err := os.WriteFile(secretPath, secretYAML, 0600); err != nil { + secretPath := filepath.Join(secretsDir, constants.TlsCertSecretYAML) + if err := os.WriteFile(secretPath, secretYAML, constants.SecretFilePerm); err != nil { return fmt.Errorf("writing secret file: %w", err) } @@ -98,7 +96,7 @@ func saveTLSSecret(ctx context.Context, k8sClient *client.Client, namespace, pro } // deleteNamespace deletes a Kubernetes namespace -func deleteNamespace(ctx context.Context, k8sClient *client.Client, namespace string) error { +func deleteNamespace(ctx context.Context, k8sClient *client.Client, namespace string, timeout time.Duration) error { clientset := k8sClient.Clientset() logger.Debug("Deleting namespace: %s", namespace) @@ -114,30 +112,5 @@ func deleteNamespace(ctx context.Context, k8sClient *client.Client, namespace st logger.Info("Namespace %s deletion initiated", namespace) logger.Debug("Waiting for namespace to be fully deleted...") - return waitForNamespaceDeletion(ctx, k8sClient, namespace) -} - -// waitForNamespaceDeletion waits for a namespace to be completely deleted -func waitForNamespaceDeletion(ctx context.Context, k8sClient *client.Client, namespace string) error { - clientset := k8sClient.Clientset() - - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - timeout := time.After(5 * time.Minute) - - for { - select { - case <-ticker.C: - _, err := clientset.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) - if err != nil { - logger.Debug("Namespace %s successfully deleted", namespace) - return nil - } - logger.Debug("Namespace %s still terminating...", namespace) - - case <-timeout: - return fmt.Errorf("timeout waiting for namespace %s to be deleted", namespace) - } - } + return waitForNamespaceDeletion(ctx, k8sClient, namespace, timeout) } diff --git a/internal/k8s/actions/update_backendmanage.go b/internal/k8s/actions/update_backendmanage.go index 58d17c7..4b40e41 100644 --- a/internal/k8s/actions/update_backendmanage.go +++ b/internal/k8s/actions/update_backendmanage.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/OpenSlides/openslides-cli/internal/constants" "github.com/OpenSlides/openslides-cli/internal/k8s/client" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/spf13/cobra" @@ -20,14 +21,11 @@ Examples: osmanage k8s update-backendmanage ./my.instance.dir.org --kubeconfig ~/.kube/config --tag 4.2.23 --container-registry myRegistry osmanage k8s update-backendmanage ./my.instance.dir.org --tag 4.2.23 --container-registry myRegistry --timeout 30s osmanage k8s update-backendmanage ./my.instance.dir.org --tag 4.2.23 --container-registry myRegistry --revert --timeout 30s` - - backendmanageDeployment = "backendmanage" - backendmanageContainer = "backendmanage" ) func UpdateBackendmanageCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "update-backendmanage ", + Use: "update-backendmanage ", Short: UpdateBackendmanageHelp, Long: UpdateBackendmanageHelp + "\n\n" + UpdateBackendmanageHelpExtra, Args: cobra.ExactArgs(1), @@ -37,7 +35,7 @@ func UpdateBackendmanageCmd() *cobra.Command { containerRegistry := cmd.Flags().String("container-registry", "", "Container registry (required)") kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") revert := cmd.Flags().Bool("revert", false, "Changes image back with given tag and registry") - timeout := cmd.Flags().Duration("timeout", 3*time.Minute, "Timeout for deployment readiness check") + timeout := cmd.Flags().Duration("timeout", constants.DefaultDeploymentTimeout, "Timeout for deployment rollout check") _ = cmd.MarkFlagRequired("tag") _ = cmd.MarkFlagRequired("container-registry") @@ -51,8 +49,8 @@ func UpdateBackendmanageCmd() *cobra.Command { } logger.Info("=== K8S UPDATE/REVERT BACKENDMANAGE ===") - projectDir := args[0] - namespace := extractNamespace(projectDir) + instanceDir := args[0] + namespace := extractNamespace(instanceDir) logger.Info("Namespace: %s", namespace) @@ -87,11 +85,11 @@ func updateBackendmanage(ctx context.Context, k8sClient *client.Client, namespac logger.Info("Updating deployment to image: %s", image) - patch := fmt.Appendf(nil, `{"spec":{"template":{"spec":{"containers":[{"name":"%s","image":"%s"}]}}}}`, backendmanageContainer, image) + patch := fmt.Appendf(nil, `{"spec":{"template":{"spec":{"containers":[{"name":"%s","image":"%s"}]}}}}`, constants.BackendmanageContainerName, image) updated, err := k8sClient.Clientset().AppsV1().Deployments(namespace).Patch( ctx, - backendmanageDeployment, + constants.BackendmanageDeploymentName, types.StrategicMergePatchType, patch, metav1.PatchOptions{}, @@ -103,7 +101,7 @@ func updateBackendmanage(ctx context.Context, k8sClient *client.Client, namespac logger.Info("Patch applied (generation: %d)", updated.Generation) logger.Info("Waiting for rollout to complete...") - if err := waitForDeploymentReady(ctx, k8sClient, namespace, backendmanageDeployment, timeout); err != nil { + if err := waitForDeploymentReady(ctx, k8sClient, namespace, constants.BackendmanageDeploymentName, timeout); err != nil { return fmt.Errorf("rollout failed: %w", err) } @@ -115,11 +113,11 @@ func revertBackendmanage(ctx context.Context, k8sClient *client.Client, namespac logger.Info("Reverting deployment to image: %s", image) - patch := fmt.Appendf(nil, `{"spec":{"template":{"spec":{"containers":[{"name":"%s","image":"%s"}]}}}}`, backendmanageContainer, image) + patch := fmt.Appendf(nil, `{"spec":{"template":{"spec":{"containers":[{"name":"%s","image":"%s"}]}}}}`, constants.BackendmanageContainerName, image) updated, err := k8sClient.Clientset().AppsV1().Deployments(namespace).Patch( ctx, - backendmanageDeployment, + constants.BackendmanageDeploymentName, types.StrategicMergePatchType, patch, metav1.PatchOptions{}, @@ -131,7 +129,7 @@ func revertBackendmanage(ctx context.Context, k8sClient *client.Client, namespac logger.Info("Patch applied (generation: %d)", updated.Generation) logger.Info("Waiting for rollout to complete...") - if err := waitForDeploymentReady(ctx, k8sClient, namespace, backendmanageDeployment, timeout); err != nil { + if err := waitForDeploymentReady(ctx, k8sClient, namespace, constants.BackendmanageDeploymentName, timeout); err != nil { return fmt.Errorf("rollout failed: %w", err) } diff --git a/internal/k8s/actions/update_instance.go b/internal/k8s/actions/update_instance.go index b6fa1cf..6ab1bff 100644 --- a/internal/k8s/actions/update_instance.go +++ b/internal/k8s/actions/update_instance.go @@ -4,8 +4,8 @@ import ( "context" "fmt" "path/filepath" - "time" + "github.com/OpenSlides/openslides-cli/internal/constants" "github.com/OpenSlides/openslides-cli/internal/k8s/client" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/spf13/cobra" @@ -13,7 +13,7 @@ import ( const ( UpdateInstanceHelp = "Updates an OpenSlides instance." - UpdateInstanceHelpExtra = `Updates the instance by applying new manifest files from the project directory. + UpdateInstanceHelpExtra = `Updates the instance by applying new manifest files from the instance directory. Examples: osmanage k8s update-instance ./my.instance.dir.org @@ -23,7 +23,7 @@ Examples: func UpdateInstanceCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "update-instance ", + Use: "update-instance ", Short: UpdateInstanceHelp, Long: UpdateInstanceHelp + "\n\n" + UpdateInstanceHelpExtra, Args: cobra.ExactArgs(1), @@ -31,15 +31,15 @@ func UpdateInstanceCmd() *cobra.Command { kubeconfig := cmd.Flags().String("kubeconfig", "", "Path to kubeconfig file") skipReadyCheck := cmd.Flags().Bool("skip-ready-check", false, "Skip waiting for instance to become ready") - timeout := cmd.Flags().Duration("timeout", 5*time.Minute, "Timeout for ready check") + timeout := cmd.Flags().Duration("timeout", constants.DefaultInstanceTimeout, "Timeout for instance health check") cmd.RunE = func(cmd *cobra.Command, args []string) error { - projectDir := args[0] - logger.Info("=== K8S UPDATE INSTANCE ===") - logger.Debug("Project directory: %s", projectDir) + instanceDir := args[0] + + logger.Debug("Instance directory: %s", instanceDir) - namespace := extractNamespace(projectDir) + namespace := extractNamespace(instanceDir) logger.Info("Namespace: %s", namespace) k8sClient, err := client.New(*kubeconfig) @@ -65,7 +65,7 @@ func UpdateInstanceCmd() *cobra.Command { logger.Info("Updating OpenSlides services.") - stackDir := filepath.Join(projectDir, "stack") + stackDir := filepath.Join(instanceDir, constants.StackDirName) if err := applyDirectory(ctx, k8sClient, stackDir); err != nil { return fmt.Errorf("applying stack: %w", err) } @@ -76,7 +76,7 @@ func UpdateInstanceCmd() *cobra.Command { } logger.Info("Waiting for instance to become ready...") - if err := waitForHealthy(ctx, k8sClient, namespace, *timeout); err != nil { + if err := waitForInstanceHealthy(ctx, k8sClient, namespace, *timeout); err != nil { return fmt.Errorf("waiting for instance health: %w", err) } diff --git a/internal/k8s/client/client.go b/internal/k8s/client/client.go index bc84e93..c3323a0 100644 --- a/internal/k8s/client/client.go +++ b/internal/k8s/client/client.go @@ -2,19 +2,21 @@ package client import ( "fmt" - "os" "path/filepath" "sync" "github.com/OpenSlides/openslides-cli/internal/logger" + "k8s.io/apimachinery/pkg/api/meta" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/restmapper" "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/util/homedir" ) +// Client wraps Kubernetes client components with lazy initialization type Client struct { clientset *kubernetes.Clientset config *rest.Config @@ -28,28 +30,33 @@ type Client struct { mapperErr error } -// New creates a Kubernetes client +// New creates a Kubernetes client from the given kubeconfig path. +// If kubeconfigPath is empty, attempts to use in-cluster config first, +// then falls back to the default kubeconfig location ($HOME/.kube/config). func New(kubeconfigPath string) (*Client, error) { var config *rest.Config var err error var source string if kubeconfigPath != "" { + // Use provided kubeconfig path config, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath) if err != nil { return nil, fmt.Errorf("failed to load kubeconfig from %s: %w", kubeconfigPath, err) } source = fmt.Sprintf("kubeconfig: %s", kubeconfigPath) } else { + // Try in-cluster config first config, err = rest.InClusterConfig() if err == nil { source = "in-cluster service account" } else { - home := os.Getenv("HOME") - if home == "" { - return nil, fmt.Errorf("failed to get in-cluster config and HOME env var not set") + // Fall back to default kubeconfig location + kubeconfigPath = getDefaultKubeconfigPath() + if kubeconfigPath == "" { + return nil, fmt.Errorf("failed to get in-cluster config and could not determine home directory") } - kubeconfigPath = filepath.Join(home, ".kube", "config") + config, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath) if err != nil { return nil, fmt.Errorf("failed to create k8s config: not running in-cluster and no valid kubeconfig found at %s: %w", kubeconfigPath, err) @@ -71,17 +78,32 @@ func New(kubeconfigPath string) (*Client, error) { }, nil } +// getDefaultKubeconfigPath returns the standard kubeconfig location +func getDefaultKubeconfigPath() string { + home := homedir.HomeDir() + if home == "" { + return "" + } + + return filepath.Join( + home, + clientcmd.RecommendedHomeDir, // ".kube" + clientcmd.RecommendedFileName, // "config" + ) +} + // Clientset returns the underlying Kubernetes clientset func (c *Client) Clientset() *kubernetes.Clientset { return c.clientset } -// Config returns the underlying Kubernetes config +// Config returns the underlying Kubernetes REST config func (c *Client) Config() *rest.Config { return c.config } -// Dynamic returns a cached dynamic client, creating it on first call +// Dynamic returns a cached dynamic client, creating it on first call. +// The dynamic client is used for working with unstructured Kubernetes resources. func (c *Client) Dynamic() (dynamic.Interface, error) { c.dynamicOnce.Do(func() { c.dynamicClient, c.dynamicErr = dynamic.NewForConfig(c.config) @@ -92,7 +114,9 @@ func (c *Client) Dynamic() (dynamic.Interface, error) { return c.dynamicClient, c.dynamicErr } -// RESTMapper returns a cached REST mapper, creating it on first call +// RESTMapper returns a cached REST mapper, creating it on first call. +// The REST mapper is used to convert between GVK (GroupVersionKind) and +// GVR (GroupVersionResource) for Kubernetes API resources. func (c *Client) RESTMapper() (meta.RESTMapper, error) { c.mapperOnce.Do(func() { apiGroupResources, err := restmapper.GetAPIGroupResources(c.clientset.Discovery()) @@ -102,7 +126,6 @@ func (c *Client) RESTMapper() (meta.RESTMapper, error) { } c.restMapper = restmapper.NewDiscoveryRESTMapper(apiGroupResources) - logger.Debug("REST mapper initialized") }) diff --git a/internal/utils/utils.go b/internal/utils/utils.go index e69ff84..6987575 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -11,11 +11,9 @@ import ( "github.com/OpenSlides/openslides-cli/internal/logger" ) -const fileMode fs.FileMode = 0666 - // CreateFile creates a file in the given directory with the given content. // Use a truthy value for force to override an existing file. -func CreateFile(dir string, force bool, name string, content []byte) error { +func CreateFile(dir string, force bool, name string, content []byte, perm fs.FileMode) error { p := path.Join(dir, name) pExists, err := fileExists(p) @@ -27,7 +25,7 @@ func CreateFile(dir string, force bool, name string, content []byte) error { return nil } - if err := os.WriteFile(p, content, fileMode); err != nil { + if err := os.WriteFile(p, content, perm); err != nil { return fmt.Errorf("creating and writing to file %q: %w", p, err) } return nil diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go index 8779eff..00769c9 100644 --- a/internal/utils/utils_test.go +++ b/internal/utils/utils_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/OpenSlides/openslides-cli/internal/constants" ) func TestReadFromFileOrStdin(t *testing.T) { @@ -118,7 +120,7 @@ func TestCreateFile(t *testing.T) { t.Run("create new file", func(t *testing.T) { content := []byte("test content") - err := CreateFile(tmpdir, false, "test.txt", content) + err := CreateFile(tmpdir, false, "test.txt", content, constants.StackFilePerm) if err != nil { t.Errorf("CreateFile() error = %v", err) } @@ -130,17 +132,26 @@ func TestCreateFile(t *testing.T) { if string(data) != string(content) { t.Errorf("File content = %s, want %s", string(data), string(content)) } + + // Verify permissions + fileInfo, err := os.Stat(filepath.Join(tmpdir, "test.txt")) + if err != nil { + t.Fatalf("failed to stat file: %v", err) + } + if fileInfo.Mode().Perm() != constants.StackFilePerm { + t.Errorf("File permissions = %v, want %v", fileInfo.Mode().Perm(), constants.StackFilePerm) + } }) t.Run("don't overwrite without force", func(t *testing.T) { filename := "existing.txt" original := []byte("original") - if err := CreateFile(tmpdir, false, filename, original); err != nil { + if err := CreateFile(tmpdir, false, filename, original, constants.StackFilePerm); err != nil { t.Fatalf("failed to create initial file: %v", err) } newContent := []byte("new content") - if err := CreateFile(tmpdir, false, filename, newContent); err != nil { + if err := CreateFile(tmpdir, false, filename, newContent, constants.StackFilePerm); err != nil { t.Fatalf("CreateFile() error = %v", err) } @@ -156,12 +167,12 @@ func TestCreateFile(t *testing.T) { t.Run("overwrite with force", func(t *testing.T) { filename := "force.txt" original := []byte("original") - if err := CreateFile(tmpdir, true, filename, original); err != nil { + if err := CreateFile(tmpdir, true, filename, original, constants.StackFilePerm); err != nil { t.Fatalf("failed to create initial file: %v", err) } newContent := []byte("new content") - if err := CreateFile(tmpdir, true, filename, newContent); err != nil { + if err := CreateFile(tmpdir, true, filename, newContent, constants.StackFilePerm); err != nil { t.Fatalf("CreateFile() error = %v", err) } @@ -173,4 +184,54 @@ func TestCreateFile(t *testing.T) { t.Error("File was not overwritten with force flag") } }) + + t.Run("create secret file with secret permissions", func(t *testing.T) { + filename := "secret.txt" + content := []byte("super secret") + err := CreateFile(tmpdir, false, filename, content, constants.SecretFilePerm) + if err != nil { + t.Errorf("CreateFile() error = %v", err) + } + + // Verify permissions + fileInfo, err := os.Stat(filepath.Join(tmpdir, filename)) + if err != nil { + t.Fatalf("failed to stat file: %v", err) + } + if fileInfo.Mode().Perm() != constants.SecretFilePerm { + t.Errorf("Secret file permissions = %v, want %v", fileInfo.Mode().Perm(), constants.SecretFilePerm) + } + }) + + t.Run("different permissions for different file types", func(t *testing.T) { + // Create a manifest file with stack permissions + manifestFile := "deployment.yaml" + if err := CreateFile(tmpdir, false, manifestFile, []byte("manifest"), constants.StackFilePerm); err != nil { + t.Fatalf("failed to create manifest file: %v", err) + } + + // Create a secret file with secret permissions + secretFile := "password" + if err := CreateFile(tmpdir, false, secretFile, []byte("secret"), constants.SecretFilePerm); err != nil { + t.Fatalf("failed to create secret file: %v", err) + } + + // Verify manifest permissions (0644) + manifestInfo, err := os.Stat(filepath.Join(tmpdir, manifestFile)) + if err != nil { + t.Fatalf("failed to stat manifest: %v", err) + } + if manifestInfo.Mode().Perm() != constants.StackFilePerm { + t.Errorf("Manifest permissions = %v, want %v", manifestInfo.Mode().Perm(), constants.StackFilePerm) + } + + // Verify secret permissions (0600) + secretInfo, err := os.Stat(filepath.Join(tmpdir, secretFile)) + if err != nil { + t.Fatalf("failed to stat secret: %v", err) + } + if secretInfo.Mode().Perm() != constants.SecretFilePerm { + t.Errorf("Secret permissions = %v, want %v", secretInfo.Mode().Perm(), constants.SecretFilePerm) + } + }) } From a9ee0f37276132c98ad7c45ee279870fd3b06630 Mon Sep 17 00:00:00 2001 From: aantoni Date: Tue, 3 Feb 2026 14:17:58 +0100 Subject: [PATCH 19/28] improve marshalContent function in config.go --- internal/instance/config/config.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/internal/instance/config/config.go b/internal/instance/config/config.go index 17a0c12..0ec31a1 100644 --- a/internal/instance/config/config.go +++ b/internal/instance/config/config.go @@ -249,13 +249,20 @@ func marshalContent(ws int, v any) (string, error) { if err != nil { return "", fmt.Errorf("marshalling content: %w", err) } - result := "\n" + + indent := strings.Repeat(" ", ws) + var result strings.Builder + result.WriteString("\n") + for line := range strings.SplitSeq(string(data), "\n") { if len(line) != 0 { - result += fmt.Sprintf("%s%s\n", strings.Repeat(" ", ws), line) + result.WriteString(indent) + result.WriteString(line) + result.WriteByte('\n') } } - return strings.TrimRight(result, "\n"), nil + + return strings.TrimRight(result.String(), "\n"), nil } func envMapToK8S(v map[string]any) []map[string]string { From b8115d10bd12f390aef061dc7b6391bdf592d135 Mon Sep 17 00:00:00 2001 From: aantoni Date: Tue, 3 Feb 2026 15:35:32 +0100 Subject: [PATCH 20/28] more constant consolidation and constants.go restructuring --- internal/constants/constants.go | 105 +++++++++++++------ internal/instance/config/config.go | 2 +- internal/instance/config/config_test.go | 16 +-- internal/instance/setup/setup.go | 5 +- internal/instance/setup/setup_test.go | 2 +- internal/k8s/actions/health_check.go | 32 +++--- internal/k8s/actions/start.go | 4 +- internal/k8s/actions/update_backendmanage.go | 8 +- 8 files changed, 106 insertions(+), 68 deletions(-) diff --git a/internal/constants/constants.go b/internal/constants/constants.go index ca3a626..6b2dd2d 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -10,18 +10,47 @@ import ( // Instance directory structure const ( + // NamespaceYAML inside the instance root directory is applied to create instance namespace + NamespaceYAML string = "namespace.yaml" + // StackDirName is the directory containing Kubernetes manifests StackDirName string = "stack" + // DeploymentFileTemplate for OpenSlides deployment filenames inside the stack dir, i. e. autoupdate-deployment.yaml + DeploymentFileTemplate string = "%s-deployment.yaml" + // SecretsDirName is the directory containing sensitive files SecretsDirName string = "secrets" - // TLS certificate secret name and filename for HTTPS - TlsCertSecret string = "tls-letsencrypt" // Kubernetes Secret resource name - TlsCertSecretYAML string = "tls-letsencrypt-secret.yaml" // Secret manifest filename + // AdminSecretsFile contains the superadmin password + AdminSecretsFile string = "superadmin" - // The template string for OpenSlides deployment filenames i. e. autoupdate-deployment.yaml - DeploymentFileTemplate string = "%s-deployment.yaml" + // PgPasswordFile contains the PostgreSQL database password + PgPasswordFile string = "postgres_password" + + // AuthTokenKey contains the authentication token secret + AuthTokenKey string = "auth_token_key" + + // AuthCookieKey contains the cookie signing secret + AuthCookieKey string = "auth_cookie_key" + + // InternalAuthPassword contains the internal service authentication password + InternalAuthPassword string = "internal_auth_password" + + // TlsCertSecret is kubernetes secret name for HTTPS + TlsCertSecret string = "tls-letsencrypt" + + // TlsCertSecretYAML is the manifest file for the kubernetes secret enabling HTTPS + TlsCertSecretYAML string = "tls-letsencrypt-secret.yaml" + + // DefaultConfigFile is the filename used, if none is set in config file(s) + DefaultConfigFile string = "os-config.yaml" + + // CertCertName is filename for the HTTPS certificate file + CertCertName string = "cert_crt" + + // CertKeyName is filename for the HTTPS key file + CertKeyName string = "cert_key" ) // File permissions @@ -42,34 +71,25 @@ const ( StackFilePerm fs.FileMode = 0644 ) -// Password generation defaults for setup +// Secret generation defaults const ( + // DefaultSuperadminPasswordLength is the default length for superadmin passwords DefaultSuperadminPasswordLength int = 20 - DefaultPostgresPasswordLength int = 40 -) -// Certificate file names (for HTTPS) for setup -const ( - CertCertName string = "cert_crt" // Certificate file - CertKeyName string = "cert_key" // Private key file -) - -// Secret file names -const ( - // AdminSecretsFile contains the superadmin password - AdminSecretsFile string = "superadmin" - - // PgPasswordFile contains the PostgreSQL database password - PgPasswordFile string = "postgres_password" - - // AuthTokenKey contains the authentication token secret - AuthTokenKey string = "auth_token_key" + // DefaultPostgresPasswordLength is the default length for database passwords + DefaultPostgresPasswordLength int = 40 - // AuthCookieKey contains the cookie signing secret - AuthCookieKey string = "auth_cookie_key" + // DefaultSecretBytesLength is the number of random bytes used for base64-encoded secrets. + // These 32 bytes produce a 44-character base64 string used for: + // - auth_token_key + // - auth_cookie_key + // - internal_auth_password + DefaultSecretBytesLength int64 = 32 - // InternalAuthPassword contains the internal service authentication password - InternalAuthPassword string = "internal_auth_password" + // PasswordCharset defines allowed characters for randomly generated passwords. + // Includes lowercase, uppercase, digits, and safe special characters. + // Used for generating postgres_password and superadmin passwords. + PasswordCharset string = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+[]" ) // Default timeouts for Kubernetes operations @@ -79,8 +99,33 @@ const ( DefaultNamespaceTimeout time.Duration = 5 * time.Minute // Wait for namespace deletion (includes finalizers) ) -// OpenSlides K8s resource names +// constants for wait functions in health_check.go const ( + // progress bar settings + ProgressBarWidth int = 40 + Saucer string = "█" + SaucerPadding string = "░" + BarStart string = "[" + BarEnd string = "]" + ThrottleDuration time.Duration = 100 * time.Millisecond + + // wait function settings + TickerDuration time.Duration = 2 * time.Second // checks health conditions every tick + IconReady string = "✓" // for pod/deployment status printouts + IconNotReady string = "✗" +) + +// OpenSlides K8s resource names and templates +const ( + // BackendmanageDeploymentName is the Kubernetes Deployment name for backendmanage BackendmanageDeploymentName string = "backendmanage" - BackendmanageContainerName string = "backendmanage" + + // BackendmanageContainerName is the container name within the backendmanage deployment + BackendmanageContainerName string = "backendmanage" + + // BackendmanageImageTemplate is the format string for backendmanage container images. + BackendmanageImageTemplate string = "%s/openslides-backend:%s" + + // BackendmanagePatchTemplate is the JSON patch template for updating the backendmanage image. + BackendmanagePatchTemplate string = `{"spec":{"template":{"spec":{"containers":[{"name":"%s","image":"%s"}]}}}}` ) diff --git a/internal/instance/config/config.go b/internal/instance/config/config.go index 0ec31a1..1f122cb 100644 --- a/internal/instance/config/config.go +++ b/internal/instance/config/config.go @@ -215,7 +215,7 @@ func getFilename(cfg map[string]any) string { if fn, ok := cfg["filename"].(string); ok && fn != "" { return fn } - return "output.yml" + return constants.DefaultConfigFile } type TemplateFunctions struct { diff --git a/internal/instance/config/config_test.go b/internal/instance/config/config_test.go index 5a7f3cd..12f63b0 100644 --- a/internal/instance/config/config_test.go +++ b/internal/instance/config/config_test.go @@ -318,8 +318,8 @@ func TestGetFilename(t *testing.T) { "other": "value", } result := getFilename(cfg) - if result != "output.yml" { - t.Errorf("Expected output.yml, got %s", result) + if result != constants.DefaultConfigFile { + t.Errorf("Expected %s, got %s", constants.DefaultConfigFile, result) } }) @@ -328,8 +328,8 @@ func TestGetFilename(t *testing.T) { "filename": "", } result := getFilename(cfg) - if result != "output.yml" { - t.Errorf("Expected output.yml for empty filename, got %s", result) + if result != constants.DefaultConfigFile { + t.Errorf("Expected %s for empty filename, got %s", constants.DefaultConfigFile, result) } }) @@ -338,8 +338,8 @@ func TestGetFilename(t *testing.T) { "filename": 123, } result := getFilename(cfg) - if result != "output.yml" { - t.Errorf("Expected output.yml for non-string filename, got %s", result) + if result != constants.DefaultConfigFile { + t.Errorf("Expected %s for non-string filename, got %s", constants.DefaultConfigFile, result) } }) } @@ -469,7 +469,7 @@ func TestCreateDirAndFiles(t *testing.T) { t.Run("invalid template path", func(t *testing.T) { cfg := map[string]any{ - "filename": "output.yml", + "filename": constants.DefaultConfigFile, } err := CreateDirAndFiles(tmpdir, false, "nonexistent-template", cfg) if err == nil { @@ -517,7 +517,7 @@ func TestCreateDirAndFiles(t *testing.T) { outDir := filepath.Join(tmpdir, "output2") cfg := map[string]any{ - "filename": "output.yml", + "filename": constants.DefaultConfigFile, } err := CreateDirAndFiles(outDir, true, tplDir, cfg) diff --git a/internal/instance/setup/setup.go b/internal/instance/setup/setup.go index 662b3f5..20b1b22 100644 --- a/internal/instance/setup/setup.go +++ b/internal/instance/setup/setup.go @@ -133,7 +133,7 @@ func randomSecret() ([]byte, error) { buf := new(bytes.Buffer) b64e := base64.NewEncoder(base64.StdEncoding, buf) - if _, err := io.Copy(b64e, io.LimitReader(rand.Reader, 32)); err != nil { + if _, err := io.Copy(b64e, io.LimitReader(rand.Reader, constants.DefaultSecretBytesLength)); err != nil { if err := b64e.Close(); err != nil { return nil, fmt.Errorf("closing base64 encoder: %w", err) } @@ -147,14 +147,13 @@ func randomSecret() ([]byte, error) { } func randomString(length int) ([]byte, error) { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+[]{}:;<>,.?" - if length <= 0 { return nil, fmt.Errorf("length must be positive, got %d", length) } result := make([]byte, length) + charset := constants.PasswordCharset maxIndex := len(charset) randomBytes := make([]byte, length) diff --git a/internal/instance/setup/setup_test.go b/internal/instance/setup/setup_test.go index 19ed6ab..f562087 100644 --- a/internal/instance/setup/setup_test.go +++ b/internal/instance/setup/setup_test.go @@ -69,7 +69,7 @@ func TestRandomString(t *testing.T) { }) t.Run("contains only allowed characters", func(t *testing.T) { - const allowedChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+[]{}:;<>,.?" + allowedChars := constants.PasswordCharset str, err := randomString(100) if err != nil { diff --git a/internal/k8s/actions/health_check.go b/internal/k8s/actions/health_check.go index 1035f48..d693889 100644 --- a/internal/k8s/actions/health_check.go +++ b/internal/k8s/actions/health_check.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/OpenSlides/openslides-cli/internal/constants" "github.com/OpenSlides/openslides-cli/internal/k8s/client" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/schollz/progressbar/v3" @@ -15,11 +16,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -const ( - // Width of the progress bar for wait functions - progressBarWidth int = 40 -) - // HealthStatus represents the health status of an instance type HealthStatus struct { Healthy bool @@ -73,9 +69,9 @@ func printHealthStatus(namespace string, status *HealthStatus) { for _, pod := range status.Pods { ready := isPodReady(&pod) - icon := "✗" + icon := constants.IconNotReady if ready { - icon = "✓" + icon = constants.IconReady } fmt.Printf(" %s %-50s %s\n", icon, pod.Name, pod.Status.Phase) } @@ -103,7 +99,7 @@ func checkHealth(ctx context.Context, k8sClient *client.Client, namespace string func waitForInstanceHealthy(ctx context.Context, k8sClient *client.Client, namespace string, timeout time.Duration) error { logger.Info("Waiting for instance to become healthy (timeout: %v)", timeout) - ticker := time.NewTicker(2 * time.Second) + ticker := time.NewTicker(constants.TickerDuration) defer ticker.Stop() timeoutCtx, cancel := context.WithTimeout(ctx, timeout) @@ -160,15 +156,15 @@ func waitForInstanceHealthy(ctx context.Context, k8sClient *client.Client, names func createProgressBar(max int, description string) *progressbar.ProgressBar { return progressbar.NewOptions(max, progressbar.OptionSetDescription(description), - progressbar.OptionSetWidth(progressBarWidth), + progressbar.OptionSetWidth(constants.ProgressBarWidth), progressbar.OptionShowCount(), progressbar.OptionSetTheme(progressbar.Theme{ - Saucer: "█", - SaucerPadding: "░", - BarStart: "[", - BarEnd: "]", + Saucer: constants.Saucer, + SaucerPadding: constants.SaucerPadding, + BarStart: constants.BarStart, + BarEnd: constants.BarEnd, }), - progressbar.OptionThrottle(100*time.Millisecond), + progressbar.OptionThrottle(constants.ThrottleDuration), progressbar.OptionClearOnFinish(), ) } @@ -212,9 +208,9 @@ func printDeploymentStatus(namespace, name string, deployment *appsv1.Deployment if len(deployment.Status.Conditions) > 0 { fmt.Println("\nConditions:") for _, condition := range deployment.Status.Conditions { - icon := "✓" + icon := constants.IconReady if condition.Status != corev1.ConditionTrue { - icon = "✗" + icon = constants.IconNotReady } fmt.Printf(" %s %-20s %s\n", icon, condition.Type, condition.Message) } @@ -226,7 +222,7 @@ func printDeploymentStatus(namespace, name string, deployment *appsv1.Deployment func waitForDeploymentReady(ctx context.Context, k8sClient *client.Client, namespace, deploymentName string, timeout time.Duration) error { logger.Debug("Waiting for deployment %s to be ready (timeout: %v)", deploymentName, timeout) - ticker := time.NewTicker(2 * time.Second) + ticker := time.NewTicker(constants.TickerDuration) defer ticker.Stop() timeoutCtx, cancel := context.WithTimeout(ctx, timeout) @@ -277,7 +273,7 @@ func waitForDeploymentReady(ctx context.Context, k8sClient *client.Client, names func waitForNamespaceDeletion(ctx context.Context, k8sClient *client.Client, namespace string, timeout time.Duration) error { clientset := k8sClient.Clientset() - ticker := time.NewTicker(2 * time.Second) + ticker := time.NewTicker(constants.TickerDuration) defer ticker.Stop() timeoutCtx, cancel := context.WithTimeout(ctx, timeout) diff --git a/internal/k8s/actions/start.go b/internal/k8s/actions/start.go index e255a25..c530547 100644 --- a/internal/k8s/actions/start.go +++ b/internal/k8s/actions/start.go @@ -19,8 +19,6 @@ Examples: osmanage k8s start ./my.instance.dir.org osmanage k8s start ./my.instance.dir.org --skip-ready-check osmanage k8s start ./my.instance.dir.org --kubeconfig ~/.kube/config --timeout 30s` - - namespaceYAML = "namespace.yaml" ) func StartCmd() *cobra.Command { @@ -47,7 +45,7 @@ func StartCmd() *cobra.Command { ctx := context.Background() - namespacePath := filepath.Join(instanceDir, namespaceYAML) + namespacePath := filepath.Join(instanceDir, constants.NamespaceYAML) namespace, err := applyManifest(ctx, k8sClient, namespacePath) if err != nil { return fmt.Errorf("applying namespace: %w", err) diff --git a/internal/k8s/actions/update_backendmanage.go b/internal/k8s/actions/update_backendmanage.go index 4b40e41..14fc2b5 100644 --- a/internal/k8s/actions/update_backendmanage.go +++ b/internal/k8s/actions/update_backendmanage.go @@ -81,11 +81,11 @@ func UpdateBackendmanageCmd() *cobra.Command { } func updateBackendmanage(ctx context.Context, k8sClient *client.Client, namespace, tag, containerRegistry string, timeout time.Duration) error { - image := fmt.Sprintf("%s/openslides-backend:%s", containerRegistry, tag) + image := fmt.Sprintf(constants.BackendmanageImageTemplate, containerRegistry, tag) logger.Info("Updating deployment to image: %s", image) - patch := fmt.Appendf(nil, `{"spec":{"template":{"spec":{"containers":[{"name":"%s","image":"%s"}]}}}}`, constants.BackendmanageContainerName, image) + patch := fmt.Appendf(nil, constants.BackendmanagePatchTemplate, constants.BackendmanageContainerName, image) updated, err := k8sClient.Clientset().AppsV1().Deployments(namespace).Patch( ctx, @@ -109,11 +109,11 @@ func updateBackendmanage(ctx context.Context, k8sClient *client.Client, namespac } func revertBackendmanage(ctx context.Context, k8sClient *client.Client, namespace, tag, containerRegistry string, timeout time.Duration) error { - image := fmt.Sprintf("%s/openslides-backend:%s", containerRegistry, tag) + image := fmt.Sprintf(constants.BackendmanageImageTemplate, containerRegistry, tag) logger.Info("Reverting deployment to image: %s", image) - patch := fmt.Appendf(nil, `{"spec":{"template":{"spec":{"containers":[{"name":"%s","image":"%s"}]}}}}`, constants.BackendmanageContainerName, image) + patch := fmt.Appendf(nil, constants.BackendmanagePatchTemplate, constants.BackendmanageContainerName, image) updated, err := k8sClient.Clientset().AppsV1().Deployments(namespace).Patch( ctx, From 788082cc462626c48d8df249bc2d535ab2da4f0c Mon Sep 17 00:00:00 2001 From: aantoni Date: Tue, 3 Feb 2026 17:59:58 +0100 Subject: [PATCH 21/28] centralizing constant, hardening flags and execution examples in backendmanage actions --- internal/constants/constants.go | 15 ++++++++ internal/manage/actions/action/action.go | 32 +++++++++++++++-- .../manage/actions/createuser/createuser.go | 32 +++++++++++++++-- .../manage/actions/initialdata/initialdata.go | 35 ++++++++++++++++--- internal/manage/actions/set/set.go | 32 ++++++++++++++--- .../manage/actions/setpassword/setpassword.go | 19 +++++++--- internal/manage/client/client.go | 21 +++++------ internal/manage/client/client_test.go | 26 +++++++------- 8 files changed, 169 insertions(+), 43 deletions(-) diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 6b2dd2d..5f8a990 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -129,3 +129,18 @@ const ( // BackendmanagePatchTemplate is the JSON patch template for updating the backendmanage image. BackendmanagePatchTemplate string = `{"spec":{"template":{"spec":{"containers":[{"name":"%s","image":"%s"}]}}}}` ) + +// OpenSlides backend API endpoints and defaults +const ( + // BackendHTTPScheme is the HTTP scheme used for backend connections + BackendHTTPScheme string = "http://" + + // BackendHandleRequestPath is the API endpoint for sending actions + BackendHandleRequestPath string = "/internal/handle_request" + + // BackendMigrationsPath is the API endpoint for migrations commands + BackendMigrationsPath string = "/internal/migrations" + + // BackendContentType is the Content-Type header for backend requests + BackendContentType string = "application/json" +) diff --git a/internal/manage/actions/action/action.go b/internal/manage/actions/action/action.go index 8aa336a..5d95478 100644 --- a/internal/manage/actions/action/action.go +++ b/internal/manage/actions/action/action.go @@ -15,7 +15,23 @@ const ( ActionHelp = "Calls an arbitrary OpenSlides action" ActionHelpExtra = `This command calls an OpenSlides backend action with the given JSON formatted payload. Provide the payload directly or use the --file flag with a -file or use this flag with - to read from stdin.` +file or use this flag with - to read from stdin. + +Examples: + osmanage action meeting.create '[{"name": "Annual Meeting", "committee_id": 1, "language": "de", "admin_ids": [1]}]' \ + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password + + osmanage action meeting.create \ + --file create_meeting.json \ + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password + + echo '[{"name": "Test Meeting", "committee_id": 1, "language": "de", "admin_ids": [1]}]' | osmanage action meeting.create \ + --file - \ + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password + ` ) func Cmd() *cobra.Command { @@ -26,11 +42,21 @@ func Cmd() *cobra.Command { Args: cobra.RangeArgs(1, 2), } - address := cmd.Flags().StringP("address", "a", "localhost:9002", "address of the OpenSlides backendManage service") - passwordFile := cmd.Flags().String("password-file", "secrets/internal_auth_password", "file with password for authorization") + address := cmd.Flags().StringP("address", "a", "", "address of the OpenSlides backendManage service (required)") + passwordFile := cmd.Flags().String("password-file", "", "file with password for authorization (required)") payloadFile := cmd.Flags().StringP("file", "f", "", "JSON file with the payload, or - for stdin") + _ = cmd.MarkFlagRequired("address") + _ = cmd.MarkFlagRequired("password-file") + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if *address == "" { + return fmt.Errorf("--address cannot be empty") + } + if *passwordFile == "" { + return fmt.Errorf("--password-file cannot be empty") + } + logger.Info("=== ACTION ===") actionName := args[0] diff --git a/internal/manage/actions/createuser/createuser.go b/internal/manage/actions/createuser/createuser.go index 45ec97c..8dad81a 100644 --- a/internal/manage/actions/createuser/createuser.go +++ b/internal/manage/actions/createuser/createuser.go @@ -15,7 +15,23 @@ const ( CreateUserHelp = "Creates a new user in OpenSlides" CreateUserHelpExtra = `This command creates a new user with the given user data in JSON format. Provide the user data as an argument, or use the --file flag with a file path, -or use --file=- to read from stdin.` +or use --file=- to read from stdin. + +Examples: + osmanage create-user '{"username": "myuser", "default_password": "mypwd"}' \ + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password + + osmanage create-user + --file user.json \ + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password + + echo '{"username": "myuser", "default_password": "mypwd"}' | osmanage create-user \ + --file - \ + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password +` ) func Cmd() *cobra.Command { @@ -26,11 +42,21 @@ func Cmd() *cobra.Command { Args: cobra.RangeArgs(0, 1), } - address := cmd.Flags().StringP("address", "a", "localhost:9002", "address of the OpenSlides backendManage service") - passwordFile := cmd.Flags().String("password-file", "secrets/internal_auth_password", "file with password for authorization") + address := cmd.Flags().StringP("address", "a", "", "address of the OpenSlides backendManage service (required)") + passwordFile := cmd.Flags().String("password-file", "", "file with password for authorization (required)") userFile := cmd.Flags().StringP("file", "f", "", "JSON file with user data, or - for stdin") + _ = cmd.MarkFlagRequired("address") + _ = cmd.MarkFlagRequired("password-file") + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if *address == "" { + return fmt.Errorf("--address cannot be empty") + } + if *passwordFile == "" { + return fmt.Errorf("--password-file cannot be empty") + } + logger.Info("=== CREATE USER ===") var input string diff --git a/internal/manage/actions/initialdata/initialdata.go b/internal/manage/actions/initialdata/initialdata.go index 9d933e7..b7ba5a7 100644 --- a/internal/manage/actions/initialdata/initialdata.go +++ b/internal/manage/actions/initialdata/initialdata.go @@ -20,7 +20,20 @@ Provide initial data via --file flag with a JSON file path, or use --file=- to r If no file is provided, empty initialization data will be used. This command also sets the superadmin (user 1) password from the superadmin password file. -It returns an error if the datastore is not empty.` +It returns an error if the datastore is not empty. + +Examples: + osmanage initial-data \ + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/initial_auth_password \ + --superadmin-password-file ./my.instance.dir.org/secrets/superadmin + + osmanage initial-data \ + --file initial.json + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/initial_auth_password \ + --superadmin-password-file ./my.instance.dir.org/secrets/superadmin +` ) func Cmd() *cobra.Command { @@ -31,12 +44,26 @@ func Cmd() *cobra.Command { Args: cobra.NoArgs, } - address := cmd.Flags().StringP("address", "a", "localhost:9002", "address of the OpenSlides backendManage service") - passwordFile := cmd.Flags().String("password-file", "secrets/internal_auth_password", "file with password for authorization") - superadminPasswordFile := cmd.Flags().String("superadmin-password-file", "secrets/superadmin", "file with superadmin password") + address := cmd.Flags().StringP("address", "a", "", "address of the OpenSlides backendManage service (required)") + passwordFile := cmd.Flags().String("password-file", "", "file with password for authorization (required)") + superadminPasswordFile := cmd.Flags().String("superadmin-password-file", "", "file with superadmin password (required)") dataFile := cmd.Flags().StringP("file", "f", "", "JSON file with initial data, or - for stdin") + _ = cmd.MarkFlagRequired("address") + _ = cmd.MarkFlagRequired("password-file") + _ = cmd.MarkFlagRequired("superadmin-password-file") + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if *address == "" { + return fmt.Errorf("--address cannot be empty") + } + if *passwordFile == "" { + return fmt.Errorf("--password-file cannot be empty") + } + if *superadminPasswordFile == "" { + return fmt.Errorf("--superadmin-password-file cannot be empty") + } + logger.Info("=== INITIAL DATA ===") var data []byte diff --git a/internal/manage/actions/set/set.go b/internal/manage/actions/set/set.go index 885cc8b..db6854a 100644 --- a/internal/manage/actions/set/set.go +++ b/internal/manage/actions/set/set.go @@ -18,8 +18,22 @@ const ( SetHelpExtra = `This command calls an OpenSlides backend action with the given JSON formatted payload. Provide the payload directly or use the --file flag with a file or use this flag with - to read from stdin. Only the following update actions are -supported: - ` +supported: [agenda_item, committee, group, meeting, motion, organization_tag, organization, projector, theme, topic, user] + +Examples: + osmanage set user '[{"id": 5, "first_name": "Jane", "last_name": "Smith"}]' + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password + + osmanage set user \ + --file user.json \ + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password + + echo '[{"id": 5, "first_name": "Jane", "last_name": "Smith"}]' | osmanage set user \ + --file - \ + --address :9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password` ) var actionMap = map[string]string{ @@ -44,11 +58,21 @@ func Cmd() *cobra.Command { Args: cobra.RangeArgs(1, 2), } - address := cmd.Flags().StringP("address", "a", "localhost:9002", "address of the OpenSlides backendManage service") - passwordFile := cmd.Flags().String("password-file", "secrets/internal_auth_password", "file with password for authorization") + address := cmd.Flags().StringP("address", "a", "", "address of the OpenSlides backendManage service (required)") + passwordFile := cmd.Flags().String("password-file", "", "file with password for authorization (required)") payloadFile := cmd.Flags().StringP("file", "f", "", "JSON file with the payload, or - for stdin") + _ = cmd.MarkFlagRequired("address") + _ = cmd.MarkFlagRequired("password-file") + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if *address == "" { + return fmt.Errorf("--address cannot be empty") + } + if *passwordFile == "" { + return fmt.Errorf("--password-file cannot be empty") + } + logger.Info("=== SET ACTION ===") action := args[0] diff --git a/internal/manage/actions/setpassword/setpassword.go b/internal/manage/actions/setpassword/setpassword.go index 4b5d5a4..2e94f9a 100644 --- a/internal/manage/actions/setpassword/setpassword.go +++ b/internal/manage/actions/setpassword/setpassword.go @@ -24,18 +24,29 @@ func Cmd() *cobra.Command { Args: cobra.NoArgs, } - address := cmd.Flags().StringP("address", "a", "localhost:9002", "address of the OpenSlides backendManage service") - passwordFile := cmd.Flags().String("password-file", "secrets/internal_auth_password", "file with password for authorization") - userID := cmd.Flags().Int64P("user_id", "u", 0, "ID of the user account") - password := cmd.Flags().StringP("password", "p", "", "new password of the user") + address := cmd.Flags().StringP("address", "a", "", "address of the OpenSlides backendManage service (required)") + passwordFile := cmd.Flags().String("password-file", "", "file with password for authorization (required)") + password := cmd.Flags().StringP("password", "p", "", "new password of the user (required)") + userID := cmd.Flags().Int64P("user_id", "u", 0, "ID of the user account (required)") + _ = cmd.MarkFlagRequired("address") + _ = cmd.MarkFlagRequired("password-file") _ = cmd.MarkFlagRequired("user_id") _ = cmd.MarkFlagRequired("password") cmd.RunE = func(cmd *cobra.Command, args []string) error { + if *address == "" { + return fmt.Errorf("--address cannot be empty") + } + if *passwordFile == "" { + return fmt.Errorf("--password-file cannot be empty") + } if *password == "" { return fmt.Errorf("--password cannot be empty") } + if *userID == 0 { + return fmt.Errorf("--user_id cannot be empty or less than 1") + } logger.Info("=== SET PASSWORD ===") logger.Debug("Setting password for user ID: %d", *userID) diff --git a/internal/manage/client/client.go b/internal/manage/client/client.go index 5e01427..fd4c13c 100644 --- a/internal/manage/client/client.go +++ b/internal/manage/client/client.go @@ -10,15 +10,10 @@ import ( "strings" "time" + "github.com/OpenSlides/openslides-cli/internal/constants" "github.com/OpenSlides/openslides-cli/internal/logger" ) -const ( - httpScheme = "http://" - handleRequestPath = "/internal/handle_request" - migrationsPath = "/internal/migrations" -) - type Client struct { address string password string @@ -36,7 +31,7 @@ func New(address, password string) *Client { // buildURL constructs the full URL from the client's address and the given path. func (c *Client) buildURL(path string) string { - return httpScheme + c.address + path + return constants.BackendHTTPScheme + c.address + path } // escapeForShell escapes single quotes in a string for safe use in shell commands. @@ -90,7 +85,7 @@ func (c *Client) SendAction(action string, rawData []byte) (*http.Response, erro return nil, fmt.Errorf("marshalling payload: %w", err) } - url := c.buildURL(handleRequestPath) + url := c.buildURL(constants.BackendHandleRequestPath) req, err := http.NewRequest("POST", url, bytes.NewReader(body)) if err != nil { @@ -99,11 +94,11 @@ func (c *Client) SendAction(action string, rawData []byte) (*http.Response, erro } authHeader := base64.StdEncoding.EncodeToString([]byte(c.password)) - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", constants.BackendContentType) req.Header.Set("Authorization", authHeader) logCurlCommand("POST", url, map[string]string{ - "Content-Type": "application/json", + "Content-Type": constants.BackendContentType, "Authorization": authHeader, }, body) @@ -135,7 +130,7 @@ func (c *Client) SendMigrations(command string) (*http.Response, error) { return nil, fmt.Errorf("marshalling payload: %w", err) } - url := c.buildURL(migrationsPath) + url := c.buildURL(constants.BackendMigrationsPath) req, err := http.NewRequest("POST", url, bytes.NewReader(body)) if err != nil { @@ -144,11 +139,11 @@ func (c *Client) SendMigrations(command string) (*http.Response, error) { } authHeader := base64.StdEncoding.EncodeToString([]byte(c.password)) - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", constants.BackendContentType) req.Header.Set("Authorization", authHeader) logCurlCommand("POST", url, map[string]string{ - "Content-Type": "application/json", + "Content-Type": constants.BackendContentType, "Authorization": authHeader, }, body) diff --git a/internal/manage/client/client_test.go b/internal/manage/client/client_test.go index 77ab702..2426c5a 100644 --- a/internal/manage/client/client_test.go +++ b/internal/manage/client/client_test.go @@ -6,6 +6,8 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/OpenSlides/openslides-cli/internal/constants" ) func TestNew(t *testing.T) { @@ -30,8 +32,8 @@ func TestBuildURL(t *testing.T) { path string want string }{ - {"handle request", handleRequestPath, "http://localhost:9002/internal/handle_request"}, - {"migrations", migrationsPath, "http://localhost:9002/internal/migrations"}, + {"handle request", constants.BackendHandleRequestPath, "http://localhost:9002/internal/handle_request"}, + {"migrations", constants.BackendMigrationsPath, "http://localhost:9002/internal/migrations"}, {"custom path", "/custom", "http://localhost:9002/custom"}, } @@ -49,14 +51,14 @@ func TestSendAction(t *testing.T) { t.Run("successful request", func(t *testing.T) { var receivedAuth string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/internal/handle_request" { - t.Errorf("Expected path /internal/handle_request, got %s", r.URL.Path) + if r.URL.Path != constants.BackendHandleRequestPath { + t.Errorf("Expected path %s, got %s", constants.BackendHandleRequestPath, r.URL.Path) } if r.Method != "POST" { t.Errorf("Expected POST, got %s", r.Method) } - if r.Header.Get("Content-Type") != "application/json" { - t.Error("Expected Content-Type: application/json") + if r.Header.Get("Content-Type") != constants.BackendContentType { + t.Errorf("Expected Content-Type: %s", constants.BackendContentType) } receivedAuth = r.Header.Get("Authorization") @@ -80,7 +82,7 @@ func TestSendAction(t *testing.T) { })) defer server.Close() - address := strings.TrimPrefix(server.URL, "http://") + address := strings.TrimPrefix(server.URL, constants.BackendHTTPScheme) cl := New(address, "test-password") resp, err := cl.SendAction("test.action", []byte(`[{"id":1}]`)) @@ -107,7 +109,7 @@ func TestSendAction(t *testing.T) { })) defer server.Close() - address := strings.TrimPrefix(server.URL, "http://") + address := strings.TrimPrefix(server.URL, constants.BackendHTTPScheme) cl := New(address, "test-password") resp, err := cl.SendAction("test.action", []byte(`[{"id":1}]`)) @@ -151,8 +153,8 @@ func TestSendAction(t *testing.T) { func TestSendMigrations(t *testing.T) { t.Run("successful migrations request", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/internal/migrations" { - t.Errorf("Expected path /internal/migrations, got %s", r.URL.Path) + if r.URL.Path != constants.BackendMigrationsPath { + t.Errorf("Expected path %s, got %s", constants.BackendMigrationsPath, r.URL.Path) } if r.Method != "POST" { t.Errorf("Expected POST, got %s", r.Method) @@ -173,7 +175,7 @@ func TestSendMigrations(t *testing.T) { })) defer server.Close() - address := strings.TrimPrefix(server.URL, "http://") + address := strings.TrimPrefix(server.URL, constants.BackendHTTPScheme) cl := New(address, "test-password") resp, err := cl.SendMigrations("stats") @@ -196,7 +198,7 @@ func TestSendMigrations(t *testing.T) { })) defer server.Close() - address := strings.TrimPrefix(server.URL, "http://") + address := strings.TrimPrefix(server.URL, constants.BackendHTTPScheme) cl := New(address, "test-password") resp, err := cl.SendMigrations("invalid") From 84b8663b22a6c024bc065d61ca56ce098124d90e Mon Sep 17 00:00:00 2001 From: aantoni Date: Wed, 4 Feb 2026 13:08:28 +0100 Subject: [PATCH 22/28] add centralized constants use and robust flag handling --- internal/manage/actions/get/get.go | 77 +++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/internal/manage/actions/get/get.go b/internal/manage/actions/get/get.go index 17656d3..070b723 100644 --- a/internal/manage/actions/get/get.go +++ b/internal/manage/actions/get/get.go @@ -15,6 +15,7 @@ import ( "golang.org/x/text/cases" "golang.org/x/text/language" + "github.com/OpenSlides/openslides-cli/internal/constants" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/OpenSlides/openslides-go/datastore" @@ -28,10 +29,29 @@ const ( Use options to narrow down output. Examples: - osmanage get user --fields first_name,last_name --filter id=1 - osmanage get user --filter is_active=true - osmanage get meeting --fields name,start_time --filter-raw '{"field":"start_time","operator":">=","value":1609459200}' - osmanage get user --filter-raw '{"and_filter":[{"field":"first_name","operator":"~=","value":"Ad"},{"field":"is_active","operator":"=","value":true}]}' + # Filter by field + osmanage get user --filter is_active=true \ + --postgres-host localhost --postgres-port 5432 \ + --postgres-user openslides --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password + + # Select specific fields + osmanage get user --fields first_name,last_name,email \ + --postgres-host localhost --postgres-port 5432 \ + --postgres-user openslides --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password + + # Complex filter with operators + osmanage get meeting --filter-raw '{"field":"start_time","operator":">=","value":1609459200}' \ + --postgres-host localhost --postgres-port 5432 \ + --postgres-user openslides --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password + + # Combined AND filter + osmanage get user --filter-raw '{"and_filter":[{"field":"first_name","operator":"~=","value":"^Ad"},{"field":"is_active","operator":"=","value":true}]}' \ + --postgres-host localhost --postgres-port 5432 \ + --postgres-user openslides --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password Supported operators in filter-raw: = : Equal @@ -42,6 +62,11 @@ Supported operators in filter-raw: <= : Less than or equal ~= : Regex match (pattern matching) +Supported collections: + - user + - meeting + - organization + Note: Filtering is done in-memory after fetching. Field selection reduces memory usage by only loading requested fields.` ) @@ -64,11 +89,18 @@ func Cmd() *cobra.Command { } // PostgreSQL connection flags - postgresHost := cmd.Flags().String("postgres-host", "localhost", "PostgreSQL host") - postgresPort := cmd.Flags().String("postgres-port", "5432", "PostgreSQL port") - postgresUser := cmd.Flags().String("postgres-user", "instance_user", "PostgreSQL user") - postgresDatabase := cmd.Flags().String("postgres-database", "instance_db", "PostgreSQL database") - postgresPasswordFile := cmd.Flags().String("postgres-password-file", "/secrets/postgres-password", "PostgreSQL password file") + postgresHost := cmd.Flags().String("postgres-host", "", "PostgreSQL host (required)") + postgresPort := cmd.Flags().String("postgres-port", "", "PostgreSQL port (required)") + postgresUser := cmd.Flags().String("postgres-user", "", "PostgreSQL user (required)") + postgresDatabase := cmd.Flags().String("postgres-database", "", "PostgreSQL database (required)") + postgresPasswordFile := cmd.Flags().String("postgres-password-file", "", "PostgreSQL password file (required)") + + // Mark PostgreSQL flags as required + _ = cmd.MarkFlagRequired("postgres-host") + _ = cmd.MarkFlagRequired("postgres-port") + _ = cmd.MarkFlagRequired("postgres-user") + _ = cmd.MarkFlagRequired("postgres-database") + _ = cmd.MarkFlagRequired("postgres-password-file") // Query flags fields := cmd.Flags().StringSlice("fields", nil, "only include the provided fields in output") @@ -102,12 +134,12 @@ func Cmd() *cobra.Command { // Create environment map for datastore connection envMap := map[string]string{ - "DATABASE_HOST": *postgresHost, - "DATABASE_PORT": *postgresPort, - "DATABASE_USER": *postgresUser, - "DATABASE_NAME": *postgresDatabase, - "DATABASE_PASSWORD_FILE": *postgresPasswordFile, - "OPENSLIDES_DEVELOPMENT": "false", + constants.EnvDatabaseHost: *postgresHost, + constants.EnvDatabasePort: *postgresPort, + constants.EnvDatabaseUser: *postgresUser, + constants.EnvDatabaseName: *postgresDatabase, + constants.EnvDatabasePasswordFile: *postgresPasswordFile, + constants.EnvOpenSlidesDevelopment: constants.DevelopmentModeDisabled, } // Initialize datastore flow @@ -163,7 +195,7 @@ func queryUsers(ctx context.Context, fetch *dsfetch.Fetch, filter map[string]str // Get user IDs from organization var userIDs []int - fetch.Organization_UserIDs(1).Lazy(&userIDs) + fetch.Organization_UserIDs(constants.DefaultOrganizationID).Lazy(&userIDs) if err := fetch.Execute(ctx); err != nil { return nil, fmt.Errorf("fetching user IDs: %w", err) } @@ -213,8 +245,8 @@ func queryMeetings(ctx context.Context, fetch *dsfetch.Fetch, filter map[string] // Get active and archived meeting IDs var activeMeetingIDs, archivedMeetingIDs []int - fetch.Organization_ActiveMeetingIDs(1).Lazy(&activeMeetingIDs) - fetch.Organization_ArchivedMeetingIDs(1).Lazy(&archivedMeetingIDs) + fetch.Organization_ActiveMeetingIDs(constants.DefaultOrganizationID).Lazy(&activeMeetingIDs) + fetch.Organization_ArchivedMeetingIDs(constants.DefaultOrganizationID).Lazy(&archivedMeetingIDs) if err := fetch.Execute(ctx); err != nil { return nil, fmt.Errorf("fetching meeting IDs: %w", err) @@ -264,21 +296,22 @@ func queryMeetings(ctx context.Context, fetch *dsfetch.Fetch, filter map[string] func queryOrganization(ctx context.Context, fetch *dsfetch.Fetch, fields []string, existsOnly bool) (any, error) { if existsOnly { var orgID int - fetch.Organization_ID(1).Lazy(&orgID) + fetch.Organization_ID(constants.DefaultOrganizationID).Lazy(&orgID) if err := fetch.Execute(ctx); err != nil { return false, nil } - return orgID == 1, nil + return orgID == constants.DefaultOrganizationID, nil } fieldsToFetch := fields if len(fieldsToFetch) == 0 { - fieldsToFetch = []string{"id", "name"} + // Use default organization fields + fieldsToFetch = strings.Split(constants.DefaultOrganizationFields, ",") } org := make(map[string]any) for _, field := range fieldsToFetch { - value, err := fetchField(fetch, "organization", 1, field) + value, err := fetchField(fetch, "organization", constants.DefaultOrganizationID, field) if err != nil { return nil, fmt.Errorf("fetching organization field %s: %w", field, err) } From 2bb3c022a2558f5f050c1fed45d949c1369813b6 Mon Sep 17 00:00:00 2001 From: aantoni Date: Wed, 4 Feb 2026 13:09:41 +0100 Subject: [PATCH 23/28] streamline migration calls, add centralized constant use, robust flags and example use --- .../manage/actions/migrations/migrations.go | 329 +++++++++--------- .../actions/migrations/migrations_test.go | 15 +- 2 files changed, 181 insertions(+), 163 deletions(-) diff --git a/internal/manage/actions/migrations/migrations.go b/internal/manage/actions/migrations/migrations.go index c92a04e..aaf5f6b 100644 --- a/internal/manage/actions/migrations/migrations.go +++ b/internal/manage/actions/migrations/migrations.go @@ -7,20 +7,57 @@ import ( "strings" "time" + "github.com/spf13/cobra" + + "github.com/OpenSlides/openslides-cli/internal/constants" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/OpenSlides/openslides-cli/internal/manage/client" "github.com/OpenSlides/openslides-cli/internal/utils" - - "github.com/spf13/cobra" ) const ( MigrationsHelp = "Wrapper to the OpenSlides backend migration tool" MigrationsHelpExtra = `Run database migrations on the OpenSlides datastore. -See help text for the respective commands for more information.` - defaultInterval = 1 * time.Second - migrationRunning = "migration_running" +Examples: + # Check migration status + osmanage migrations stats \ + --address :9002 \ + --password-file my.instance.dir/secrets/internal_auth_password + + # Prepare migrations (dry run) + osmanage migrations migrate \ + --address :9002 \ + --password-file my.instance.dir/secrets/internal_auth_password + + # Apply migrations to datastore + osmanage migrations finalize \ + --address :9002 \ + --password-file my.instance.dir/secrets/internal_auth_password + + # Reset unapplied migrations + osmanage migrations reset \ + --address :9002 \ + --password-file my.instance.dir/secrets/internal_auth_password + + # Check progress of running migration + osmanage migrations progress \ + --address :9002 \ + --password-file my.instance.dir/secrets/internal_auth_password + + # Custom progress interval + osmanage migrations finalize \ + --address :9002 \ + --password-file my.instance.dir/secrets/internal_auth_password \ + --interval 2s + +Available commands: + migrate Prepare migrations (dry run) + finalize Apply migrations to datastore + reset Reset unapplied migrations + clear-collectionfield-tables Clear auxiliary tables (offline only) + stats Show migration statistics + progress Check running migration progress` ) func Cmd() *cobra.Command { @@ -43,71 +80,51 @@ func Cmd() *cobra.Command { } func migrateCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "migrate", - Short: "Prepare migrations but do not apply them to the datastore", - Args: cobra.NoArgs, - } - return setupMigrationCmd(cmd, true) + return createMigrationCmd("migrate", "Prepare migrations but do not apply them to the datastore", true) } func finalizeCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "finalize", - Short: "Prepare migrations and apply them to the datastore", - Args: cobra.NoArgs, - } - return setupMigrationCmd(cmd, true) + return createMigrationCmd("finalize", "Prepare migrations and apply them to the datastore", true) } func resetCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "reset", - Short: "Reset unapplied migrations", - Args: cobra.NoArgs, - } - return setupMigrationCmd(cmd, false) + return createMigrationCmd("reset", "Reset unapplied migrations", false) } func clearCollectionfieldTablesCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "clear-collectionfield-tables", - Short: "Clear all data from auxiliary tables (only when OpenSlides is offline)", - Args: cobra.NoArgs, - } - return setupMigrationCmd(cmd, false) + return createMigrationCmd("clear-collectionfield-tables", "Clear all data from auxiliary tables (only when OpenSlides is offline)", false) } func statsCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "stats", - Short: "Print statistics about the current migration state", - Args: cobra.NoArgs, - } - return setupMigrationCmd(cmd, false) + return createMigrationCmd("stats", "Print statistics about the current migration state", false) } func progressCmd() *cobra.Command { + return createMigrationCmd("progress", "Query the progress of a currently running migration command", false) +} + +// createMigrationCmd creates a migration subcommand with standard flags +func createMigrationCmd(name, description string, withProgressTracking bool) *cobra.Command { cmd := &cobra.Command{ - Use: "progress", - Short: "Query the progress of a currently running migration command", + Use: name, + Short: description, Args: cobra.NoArgs, } - return setupMigrationCmd(cmd, false) -} -func setupMigrationCmd(cmd *cobra.Command, withInterval bool) *cobra.Command { - address := cmd.Flags().StringP("address", "a", "localhost:9002", "address of the OpenSlides backendManage service") - passwordFile := cmd.Flags().String("password-file", "secrets/internal_auth_password", "file with password for authorization") + address := cmd.Flags().StringP("address", "a", "", "address of the OpenSlides backendManage service (required)") + passwordFile := cmd.Flags().String("password-file", "", "file with password for authorization (required)") - var interval *time.Duration - if withInterval { - interval = cmd.Flags().Duration("interval", defaultInterval, - "interval of progress calls on running migrations, set 0 to disable progress") + _ = cmd.MarkFlagRequired("address") + _ = cmd.MarkFlagRequired("password-file") + + var progressInterval *time.Duration + if withProgressTracking { + progressInterval = cmd.Flags().Duration("interval", constants.DefaultMigrationProgressInterval, + "interval for progress checks (set 0 to disable progress tracking)") } cmd.RunE = func(cmd *cobra.Command, args []string) error { - logger.Info("=== MIGRATIONS: %s ===", strings.ToUpper(cmd.Use)) + logger.Info("=== MIGRATIONS: %s ===", strings.ToUpper(name)) authPassword, err := utils.ReadPassword(*passwordFile) if err != nil { @@ -116,136 +133,133 @@ func setupMigrationCmd(cmd *cobra.Command, withInterval bool) *cobra.Command { cl := client.New(*address, authPassword) - return runMigrations(cl, cmd.Use, interval) - } - - return cmd -} - -func runMigrations(cl *client.Client, command string, intervalFlag *time.Duration) error { - logger.Debug("Running migrations command: %s", command) - - mR, err := executeMigrationsCommand(cl, command) - if err != nil { - return fmt.Errorf("executing migrations command: %w", err) - } - - var interval time.Duration - if intervalFlag != nil { - interval = *intervalFlag - } - - // If no interval or not running, just print and return - if interval == 0 || !mR.Running() { - output, err := mR.GetOutput(command) + // Execute migration command + response, err := sendMigrationCommand(cl, name) if err != nil { - return fmt.Errorf("parsing migrations response: %w", err) + return fmt.Errorf("executing migration command: %w", err) } - fmt.Print(output) - return nil - } - - // Track progress with intervals - fmt.Println("Progress:") - logger.Debug("Starting progress tracking with interval: %v", interval) - for { - time.Sleep(interval) - - mR, err := executeMigrationsCommand(cl, "progress") - if err != nil { - return fmt.Errorf("checking progress: %w", err) + // Handle response based on whether progress tracking is enabled + if withProgressTracking && progressInterval != nil && *progressInterval > 0 && response.Running() { + return trackMigrationProgress(cl, response, *progressInterval, name) } - if mR.Faulty() { - logger.Error("Migration command failed") - out, err := mR.GetOutput("progress") - if err != nil { - return fmt.Errorf("parsing error response: %w", err) - } - fmt.Print(out) - } else { - out, err := mR.GetOutput("progress") - if err != nil { - return fmt.Errorf("error parsing progress output: %w", err) - } - fmt.Print(out) + // No progress tracking - just print output + output, err := response.GetOutput(name) + if err != nil { + return fmt.Errorf("formatting output: %w", err) } - if !mR.Running() { - logger.Info("Migration completed") - break - } + fmt.Print(output) + return nil } - return nil + return cmd } -func executeMigrationsCommand(cl *client.Client, command string) (MigrationResponse, error) { - logger.Debug("Executing migrations command: %s", command) - - const maxRetries = 5 - const retryDelay = 5 * time.Second - const totalTimeout = 3 * time.Minute // Max time for all retries combined +// sendMigrationCommand sends a migration command with retry logic +func sendMigrationCommand(cl *client.Client, command string) (*MigrationResponse, error) { + logger.Debug("Sending migration command: %s", command) - ctx, cancel := context.WithTimeout(context.Background(), totalTimeout) + ctx, cancel := context.WithTimeout(context.Background(), constants.MigrationTotalTimeout) defer cancel() var lastErr error - for attempt := 0; attempt < maxRetries; attempt++ { + + for attempt := 0; attempt < constants.MigrationMaxRetries; attempt++ { // Check if context expired if ctx.Err() != nil { - return MigrationResponse{}, fmt.Errorf("migrations command timed out after %v: %w", totalTimeout, ctx.Err()) + return nil, fmt.Errorf("migration command timed out after %v: %w", constants.MigrationTotalTimeout, ctx.Err()) } + // Wait before retry (except first attempt) if attempt > 0 { logger.Warn("Retry attempt %d/%d after %v (previous error: %v)", - attempt, maxRetries, retryDelay, lastErr) + attempt, constants.MigrationMaxRetries, constants.MigrationRetryDelay, lastErr) - // Sleep with context awareness select { - case <-time.After(retryDelay): + case <-time.After(constants.MigrationRetryDelay): // Continue to next attempt case <-ctx.Done(): - return MigrationResponse{}, fmt.Errorf("migrations command cancelled during retry: %w", ctx.Err()) + return nil, fmt.Errorf("migration command cancelled during retry: %w", ctx.Err()) } } + // Send request resp, err := cl.SendMigrations(command) if err != nil { - lastErr = fmt.Errorf("sending migrations request: %w", err) - if isRetryableError(err) && attempt < maxRetries-1 { + lastErr = fmt.Errorf("sending request: %w", err) + if isRetryableError(err) && attempt < constants.MigrationMaxRetries-1 { logger.Debug("Retryable error: %v", err) continue } - return MigrationResponse{}, lastErr + return nil, lastErr } + // Check response body, err := client.CheckResponse(resp) if err != nil { lastErr = err - if isRetryableError(err) && attempt < maxRetries-1 { + if isRetryableError(err) && attempt < constants.MigrationMaxRetries-1 { logger.Debug("Retryable error: %v", err) continue } - return MigrationResponse{}, lastErr + return nil, lastErr } - var mR MigrationResponse - if err := json.Unmarshal(body, &mR); err != nil { - logger.Error("Failed to unmarshal migrations response: %v", err) - return MigrationResponse{}, fmt.Errorf("unmarshalling migration response: %w", err) + // Parse response + var migrationResp MigrationResponse + if err := json.Unmarshal(body, &migrationResp); err != nil { + logger.Error("Failed to unmarshal migration response: %v", err) + return nil, fmt.Errorf("unmarshalling response: %w", err) } logger.Debug("Migration response - Success: %v, Status: %s, Running: %v", - mR.Success, mR.Status, mR.Running()) + migrationResp.Success, migrationResp.Status, migrationResp.Running()) - return mR, nil + return &migrationResp, nil } - return MigrationResponse{}, fmt.Errorf("migrations command failed after %d retries: %w", maxRetries, lastErr) + return nil, fmt.Errorf("migration command failed after %d retries: %w", constants.MigrationMaxRetries, lastErr) } +// trackMigrationProgress polls migration progress until completion +func trackMigrationProgress(cl *client.Client, initialResponse *MigrationResponse, interval time.Duration, command string) error { + fmt.Println("Progress:") + logger.Debug("Starting progress tracking with interval: %v", interval) + + for { + time.Sleep(interval) + + response, err := sendMigrationCommand(cl, "progress") + if err != nil { + return fmt.Errorf("checking progress: %w", err) + } + + // Print progress output + output, err := response.GetOutput("progress") + if err != nil { + return fmt.Errorf("formatting progress output: %w", err) + } + fmt.Print(output) + + // Check if migration failed + if response.Faulty() { + logger.Error("Migration command failed") + return fmt.Errorf("migration failed: %s", response.Exception) + } + + // Check if migration completed + if !response.Running() { + logger.Info("Migration completed") + break + } + } + + return nil +} + +// isRetryableError determines if an error should trigger a retry func isRetryableError(err error) bool { if err == nil { return false @@ -253,7 +267,8 @@ func isRetryableError(err error) bool { errStr := strings.ToLower(err.Error()) - retryableErrors := []string{ + // Network-related errors + retryablePatterns := []string{ "connection refused", "connection reset", "timeout", @@ -265,22 +280,24 @@ func isRetryableError(err error) bool { "i/o timeout", } - for _, retryable := range retryableErrors { - if strings.Contains(errStr, retryable) { + for _, pattern := range retryablePatterns { + if strings.Contains(errStr, pattern) { return true } } - if strings.Contains(errStr, "server error") || - strings.Contains(errStr, "503") || - strings.Contains(errStr, "502") || - strings.Contains(errStr, "504") { - return true + // HTTP server errors (5xx) + serverErrors := []string{"server error", "503", "502", "504"} + for _, code := range serverErrors { + if strings.Contains(errStr, code) { + return true + } } return false } +// MigrationResponse represents the response from a migration command type MigrationResponse struct { Success bool `json:"success"` Status string `json:"status"` @@ -289,35 +306,26 @@ type MigrationResponse struct { Stats json.RawMessage `json:"stats"` } -func (mR MigrationResponse) GetOutput(command string) (string, error) { - if mR.Faulty() { - return mR.formatAll() +// GetOutput returns the formatted output for the migration response +func (mr *MigrationResponse) GetOutput(command string) (string, error) { + if mr.Faulty() { + return mr.formatAll() } if command == "stats" { - return mR.formatStats() + return mr.formatStats() } - return mR.Output, nil + return mr.Output, nil } -func (mR MigrationResponse) formatStats() (string, error) { +// formatStats formats the stats JSON into a readable string +func (mr *MigrationResponse) formatStats() (string, error) { var stats map[string]any - if err := json.Unmarshal(mR.Stats, &stats); err != nil { + if err := json.Unmarshal(mr.Stats, &stats); err != nil { return "", fmt.Errorf("unmarshalling stats: %w", err) } - // Define the order we want fields printed - orderedFields := []string{ - "current_migration_index", - "target_migration_index", - "positions", - "events", - "partially_migrated_positions", - "fully_migrated_positions", - "status", - } - var sb strings.Builder - for _, field := range orderedFields { + for _, field := range constants.MigrationStatsFields { if value, ok := stats[field]; ok { sb.WriteString(fmt.Sprintf("%s: %v\n", field, value)) } @@ -326,15 +334,18 @@ func (mR MigrationResponse) formatStats() (string, error) { return sb.String(), nil } -func (mR MigrationResponse) formatAll() (string, error) { +// formatAll formats all response fields +func (mr *MigrationResponse) formatAll() (string, error) { return fmt.Sprintf("Success: %v\nStatus: %s\nOutput: %s\nException: %s\n", - mR.Success, mR.Status, mR.Output, mR.Exception), nil + mr.Success, mr.Status, mr.Output, mr.Exception), nil } -func (mR MigrationResponse) Faulty() bool { - return !mR.Success || mR.Exception != "" +// Faulty returns true if the migration failed +func (mr *MigrationResponse) Faulty() bool { + return !mr.Success || mr.Exception != "" } -func (mR MigrationResponse) Running() bool { - return mR.Status == migrationRunning +// Running returns true if the migration is currently in progress +func (mr *MigrationResponse) Running() bool { + return mr.Status == constants.MigrationStatusRunning } diff --git a/internal/manage/actions/migrations/migrations_test.go b/internal/manage/actions/migrations/migrations_test.go index 8f3600b..346822d 100644 --- a/internal/manage/actions/migrations/migrations_test.go +++ b/internal/manage/actions/migrations/migrations_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "strings" "testing" + + "github.com/OpenSlides/openslides-cli/internal/constants" ) func TestMigrationResponse_Faulty(t *testing.T) { @@ -43,7 +45,7 @@ func TestMigrationResponse_Running(t *testing.T) { status string running bool }{ - {"running", "migration_running", true}, + {"running", constants.MigrationStatusRunning, true}, {"completed", "completed", false}, {"failed", "failed", false}, {"empty", "", false}, @@ -72,6 +74,7 @@ func TestMigrationResponse_GetOutput(t *testing.T) { t.Errorf("Expected 'Migration completed', got %s", output) } }) + t.Run("stats command", func(t *testing.T) { stats := map[string]any{ "current_migration_index": 68, @@ -93,6 +96,7 @@ func TestMigrationResponse_GetOutput(t *testing.T) { } // Verify all expected fields are present + // Using subset of MigrationStatsFields for validation expectedFields := []string{ "current_migration_index", "target_migration_index", @@ -106,6 +110,7 @@ func TestMigrationResponse_GetOutput(t *testing.T) { } } }) + t.Run("faulty response", func(t *testing.T) { resp := MigrationResponse{ Success: false, @@ -133,7 +138,7 @@ func TestMigrationResponse_FormatStats(t *testing.T) { "fully_migrated_positions": 0, } statsJSON, _ := json.Marshal(stats) - resp := MigrationResponse{Stats: statsJSON} + resp := &MigrationResponse{Stats: statsJSON} output, err := resp.formatStats() if err != nil { @@ -171,7 +176,7 @@ func TestMigrationResponse_FormatStats(t *testing.T) { "current_migration_index": 70, } statsJSON, _ := json.Marshal(stats) - resp := MigrationResponse{Stats: statsJSON} + resp := &MigrationResponse{Stats: statsJSON} output, err := resp.formatStats() if err != nil { @@ -188,7 +193,7 @@ func TestMigrationResponse_FormatStats(t *testing.T) { }) t.Run("invalid JSON", func(t *testing.T) { - resp := MigrationResponse{Stats: json.RawMessage("invalid json")} + resp := &MigrationResponse{Stats: json.RawMessage("invalid json")} _, err := resp.formatStats() if err == nil { @@ -209,6 +214,8 @@ func TestIsRetryableError(t *testing.T) { {"timeout", "i/o timeout", true}, {"eof", "unexpected EOF", true}, {"server error 503", "server returned 503", true}, + {"server error 502", "bad gateway 502", true}, + {"server error 504", "gateway timeout 504", true}, {"client error 404", "404 not found", false}, {"auth error", "unauthorized", false}, {"parse error", "invalid JSON", false}, From 68632aeefbefb05f7ca013ae011a6e5157737189 Mon Sep 17 00:00:00 2001 From: aantoni Date: Wed, 4 Feb 2026 13:11:21 +0100 Subject: [PATCH 24/28] add the get.go and migrations.go constant fields to constants.go --- internal/constants/constants.go | 68 +++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 5f8a990..d06807b 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -144,3 +144,71 @@ const ( // BackendContentType is the Content-Type header for backend requests BackendContentType string = "application/json" ) + +// PostgreSQL datastore environment variable keys (used by get command) +const ( + // EnvDatabaseHost is the environment variable for PostgreSQL host + EnvDatabaseHost string = "DATABASE_HOST" + + // EnvDatabasePort is the environment variable for PostgreSQL port + EnvDatabasePort string = "DATABASE_PORT" + + // EnvDatabaseUser is the environment variable for PostgreSQL user + EnvDatabaseUser string = "DATABASE_USER" + + // EnvDatabaseName is the environment variable for PostgreSQL database name + EnvDatabaseName string = "DATABASE_NAME" + + // EnvDatabasePasswordFile is the environment variable for PostgreSQL password file path + EnvDatabasePasswordFile string = "DATABASE_PASSWORD_FILE" + + // EnvOpenSlidesDevelopment is the environment variable for development mode + EnvOpenSlidesDevelopment string = "OPENSLIDES_DEVELOPMENT" +) + +// PostgreSQL datastore environment variable values +const ( + // DevelopmentModeDisabled is the value to disable OpenSlides development mode + DevelopmentModeDisabled string = "false" + + // DevelopmentModeEnabled is the value to enable OpenSlides development mode + DevelopmentModeEnabled string = "true" +) + +// OpenSlides datastore defaults +const ( + // DefaultOrganizationID is the organization ID in OpenSlides (always 1) + DefaultOrganizationID int = 1 + + // DefaultOrganizationFields are the default fields fetched for organization queries + DefaultOrganizationFields string = "id,name" +) + +// Migration command defaults and configuration +const ( + // DefaultMigrationProgressInterval is the default interval for checking migration progress + DefaultMigrationProgressInterval time.Duration = 1 * time.Second + + // MigrationStatusRunning indicates a migration is currently in progress + MigrationStatusRunning string = "migration_running" + + // MigrationMaxRetries is the maximum number of retry attempts for failed migration requests + MigrationMaxRetries int = 5 + + // MigrationRetryDelay is the delay between retry attempts + MigrationRetryDelay time.Duration = 5 * time.Second + + // MigrationTotalTimeout is the maximum time allowed for all retry attempts + MigrationTotalTimeout time.Duration = 3 * time.Minute +) + +// Migration stats field names (for ordered output) +var MigrationStatsFields = []string{ + "current_migration_index", + "target_migration_index", + "positions", + "events", + "partially_migrated_positions", + "fully_migrated_positions", + "status", +} From cef18e3278e55cca7bfbd6f7df1e11d17963f895 Mon Sep 17 00:00:00 2001 From: aantoni Date: Wed, 4 Feb 2026 13:11:47 +0100 Subject: [PATCH 25/28] small fixes --- internal/instance/create/create.go | 5 +++-- internal/k8s/actions/scale.go | 3 ++- internal/k8s/actions/update_backendmanage.go | 5 +++-- internal/manage/actions/action/action.go | 7 ------- internal/manage/actions/createuser/createuser.go | 7 ------- internal/manage/actions/initialdata/initialdata.go | 9 ++------- internal/manage/actions/set/set.go | 7 ------- internal/manage/actions/setpassword/setpassword.go | 9 ++------- 8 files changed, 12 insertions(+), 40 deletions(-) diff --git a/internal/instance/create/create.go b/internal/instance/create/create.go index 7f37939..f1ea84f 100644 --- a/internal/instance/create/create.go +++ b/internal/instance/create/create.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/OpenSlides/openslides-cli/internal/constants" "github.com/OpenSlides/openslides-cli/internal/logger" @@ -43,10 +44,10 @@ func Cmd() *cobra.Command { _ = cmd.MarkFlagRequired("superadmin-password") cmd.RunE = func(cmd *cobra.Command, args []string) error { - if *dbPassword == "" { + if strings.TrimSpace(*dbPassword) == "" { return fmt.Errorf("--db-password cannot be empty") } - if *superadminPassword == "" { + if strings.TrimSpace(*superadminPassword) == "" { return fmt.Errorf("--superadmin-password cannot be empty") } diff --git a/internal/k8s/actions/scale.go b/internal/k8s/actions/scale.go index 70c8abd..f2c94c4 100644 --- a/internal/k8s/actions/scale.go +++ b/internal/k8s/actions/scale.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "path/filepath" + "strings" "github.com/OpenSlides/openslides-cli/internal/constants" "github.com/OpenSlides/openslides-cli/internal/k8s/client" @@ -39,7 +40,7 @@ func ScaleCmd() *cobra.Command { _ = cmd.MarkFlagRequired("service") cmd.RunE = func(cmd *cobra.Command, args []string) error { - if *service == "" { + if strings.TrimSpace(*service) == "" { return fmt.Errorf("--service cannot be empty") } diff --git a/internal/k8s/actions/update_backendmanage.go b/internal/k8s/actions/update_backendmanage.go index 14fc2b5..f715983 100644 --- a/internal/k8s/actions/update_backendmanage.go +++ b/internal/k8s/actions/update_backendmanage.go @@ -3,6 +3,7 @@ package actions import ( "context" "fmt" + "strings" "time" "github.com/OpenSlides/openslides-cli/internal/constants" @@ -41,10 +42,10 @@ func UpdateBackendmanageCmd() *cobra.Command { _ = cmd.MarkFlagRequired("container-registry") cmd.RunE = func(cmd *cobra.Command, args []string) error { - if *tag == "" { + if strings.TrimSpace(*tag) == "" { return fmt.Errorf("--tag cannot be empty") } - if *containerRegistry == "" { + if strings.TrimSpace(*containerRegistry) == "" { return fmt.Errorf("--container-registry cannot be empty") } diff --git a/internal/manage/actions/action/action.go b/internal/manage/actions/action/action.go index 5d95478..a7dcc9b 100644 --- a/internal/manage/actions/action/action.go +++ b/internal/manage/actions/action/action.go @@ -50,13 +50,6 @@ func Cmd() *cobra.Command { _ = cmd.MarkFlagRequired("password-file") cmd.RunE = func(cmd *cobra.Command, args []string) error { - if *address == "" { - return fmt.Errorf("--address cannot be empty") - } - if *passwordFile == "" { - return fmt.Errorf("--password-file cannot be empty") - } - logger.Info("=== ACTION ===") actionName := args[0] diff --git a/internal/manage/actions/createuser/createuser.go b/internal/manage/actions/createuser/createuser.go index 8dad81a..c2668c8 100644 --- a/internal/manage/actions/createuser/createuser.go +++ b/internal/manage/actions/createuser/createuser.go @@ -50,13 +50,6 @@ func Cmd() *cobra.Command { _ = cmd.MarkFlagRequired("password-file") cmd.RunE = func(cmd *cobra.Command, args []string) error { - if *address == "" { - return fmt.Errorf("--address cannot be empty") - } - if *passwordFile == "" { - return fmt.Errorf("--password-file cannot be empty") - } - logger.Info("=== CREATE USER ===") var input string diff --git a/internal/manage/actions/initialdata/initialdata.go b/internal/manage/actions/initialdata/initialdata.go index b7ba5a7..62e2cab 100644 --- a/internal/manage/actions/initialdata/initialdata.go +++ b/internal/manage/actions/initialdata/initialdata.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/OpenSlides/openslides-cli/internal/manage/client" @@ -54,13 +55,7 @@ func Cmd() *cobra.Command { _ = cmd.MarkFlagRequired("superadmin-password-file") cmd.RunE = func(cmd *cobra.Command, args []string) error { - if *address == "" { - return fmt.Errorf("--address cannot be empty") - } - if *passwordFile == "" { - return fmt.Errorf("--password-file cannot be empty") - } - if *superadminPasswordFile == "" { + if strings.TrimSpace(*superadminPasswordFile) == "" { return fmt.Errorf("--superadmin-password-file cannot be empty") } diff --git a/internal/manage/actions/set/set.go b/internal/manage/actions/set/set.go index db6854a..fde5945 100644 --- a/internal/manage/actions/set/set.go +++ b/internal/manage/actions/set/set.go @@ -66,13 +66,6 @@ func Cmd() *cobra.Command { _ = cmd.MarkFlagRequired("password-file") cmd.RunE = func(cmd *cobra.Command, args []string) error { - if *address == "" { - return fmt.Errorf("--address cannot be empty") - } - if *passwordFile == "" { - return fmt.Errorf("--password-file cannot be empty") - } - logger.Info("=== SET ACTION ===") action := args[0] diff --git a/internal/manage/actions/setpassword/setpassword.go b/internal/manage/actions/setpassword/setpassword.go index 2e94f9a..848d97c 100644 --- a/internal/manage/actions/setpassword/setpassword.go +++ b/internal/manage/actions/setpassword/setpassword.go @@ -3,6 +3,7 @@ package setpassword import ( "encoding/json" "fmt" + "strings" "github.com/OpenSlides/openslides-cli/internal/logger" "github.com/OpenSlides/openslides-cli/internal/manage/client" @@ -35,13 +36,7 @@ func Cmd() *cobra.Command { _ = cmd.MarkFlagRequired("password") cmd.RunE = func(cmd *cobra.Command, args []string) error { - if *address == "" { - return fmt.Errorf("--address cannot be empty") - } - if *passwordFile == "" { - return fmt.Errorf("--password-file cannot be empty") - } - if *password == "" { + if strings.TrimSpace(*password) == "" { return fmt.Errorf("--password cannot be empty") } if *userID == 0 { From 177eab98a8980daa53d3ae7e06ee6ae574b6849e Mon Sep 17 00:00:00 2001 From: aantoni Date: Wed, 4 Feb 2026 18:51:45 +0100 Subject: [PATCH 26/28] update README.md for k8s actions integration and refactoring --- README.md | 1094 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 818 insertions(+), 276 deletions(-) diff --git a/README.md b/README.md index e901e3e..48e7dba 100644 --- a/README.md +++ b/README.md @@ -11,22 +11,36 @@ # osmanage -A command-line interface for managing OpenSlides instances. This tool provides direct access to OpenSlides backend actions, datastore queries, database migrations, and deployment setup. +A command-line interface for managing OpenSlides instances. This tool provides deployment automation, Kubernetes orchestration, direct access to OpenSlides backend actions, datastore queries, and database migrations. ## Table of Contents - [Overview](#overview) - [Installation](#installation) - [Quick Start](#quick-start) + - [Docker Compose Setup](#docker-compose-setup) - [Commands](#commands) - - [setup](#setup) - - [action](#action) - - [create-user](#create-user) - - [get](#get) - - [initial-data](#initial-data) - - [migrations](#migrations) - - [set](#set) - - [set-password](#set-password) + - [Instance Management](#instance-management) + - [setup](#setup) + - [config](#config) + - [create](#create) + - [remove](#remove) + - [Kubernetes Operations](#kubernetes-operations) + - [k8s start](#k8s-start) + - [k8s stop](#k8s-stop) + - [k8s update-instance](#k8s-update-instance) + - [k8s update-backendmanage](#k8s-update-backendmanage) + - [k8s scale](#k8s-scale) + - [k8s health](#k8s-health) + - [k8s cluster-status](#k8s-cluster-status) + - [Backend Actions](#backend-actions) + - [action](#action) + - [create-user](#create-user) + - [get](#get) + - [initial-data](#initial-data) + - [migrations](#migrations) + - [set](#set) + - [set-password](#set-password) - [Configuration](#configuration) - [Examples](#examples) - [Development](#development) @@ -37,10 +51,11 @@ A command-line interface for managing OpenSlides instances. This tool provides d ## Overview -`osmanage` is a comprehensive management utility for OpenSlides 4.x instances. It combines deployment automation, database operations, and administrative tasks into a single tool. +`osmanage` is a comprehensive management utility for OpenSlides 4.x instances. It combines deployment automation, Kubernetes orchestration, database operations, and administrative tasks into a single tool. **Key Features:** - **Deployment Setup**: Generate Docker Compose and Kubernetes configurations +- **Kubernetes Management**: Deploy, update, and manage OpenSlides in Kubernetes - **Secrets Management**: Automatic generation of secure passwords and certificates - **Datastore Queries**: Direct PostgreSQL access with advanced filtering - **Database Migrations**: Manage OpenSlides schema migrations with progress tracking @@ -54,7 +69,6 @@ A command-line interface for managing OpenSlides instances. This tool provides d ### Binary Release Download the latest binary from the [releases page](https://github.com/OpenSlides/openslides-cli/releases): - ```bash # Linux AMD64 curl -L https://github.com/OpenSlides/openslides-cli/releases/latest/download/osmanage -o osmanage @@ -66,7 +80,6 @@ sudo mv osmanage /usr/local/bin/ **Requirements:** - Go 1.23 or later - ```bash git clone https://github.com/OpenSlides/openslides-cli.git cd openslides-cli @@ -75,21 +88,19 @@ CGO_ENABLED=0 go build -a -ldflags="-s -w" ./cmd/osmanage --- - ## Quick Start -### Standard Setup (Auto-Initialize) - -If your config has `OPENSLIDES_BACKEND_CREATE_INITIAL_DATA: 1` set (default): +### Docker Compose Setup +Standard workflow for Docker Compose deployments: ```bash -# 1. Generate deployment configuration -osmanage setup ./openslides-deployment \ +# 1. Generate deployment configuration with random secrets +osmanage setup ./my.instance.dir.org \ --config config.yml \ - --template templates/docker-compose.yml + --template docker-compose.yml # 2. Start services -cd openslides-deployment +cd my.instance.dir.org docker compose up -d # 3. Wait for services to be ready (~30 seconds) @@ -110,38 +121,479 @@ docker compose down -v ## Commands -### `setup` +### Instance Management + +Commands for creating and managing OpenSlides instance directories. -Creates deployment configuration files, secrets, and SSL certificates. +--- + +#### `setup` + +Creates a new instance directory with deployment configuration, secrets, and SSL certificates. **Usage:** ```bash -osmanage setup [flags] +osmanage setup [flags] ``` **Flags:** -- `-t, --template `: Custom template file or directory -- `-c, --config `: YAML config file(s) (can be used multiple times) +- `-t, --template `: Template file or directory (required) +- `-c, --config `: YAML config file(s) (can be used multiple times, required) - `-f, --force`: Overwrite existing files -**Generated Files:** -- `docker-compose.yml` or Kubernetes manifests -- `secrets/` directory with: - - `auth_token_key` - - `auth_cookie_key` - - `internal_auth_password` - - `postgres_password` - - `superadmin` - - `cert_crt` and `cert_key` (if HTTPS enabled) +**Generated Structure:** +``` +my.instance.dir.org/ +├── docker-compose.yml # (if using Docker Compose template) +├── namespace.yaml # (if using Kubernetes template) +├── stack/ # (if using Kubernetes template) +│ ├── autoupdate-deployment.yaml +│ ├── backend-deployment.yaml +│ ├── postgres-deployment.yaml +│ └── ... +└── secrets/ + ├── auth_token_key + ├── auth_cookie_key + ├── internal_auth_password + ├── postgres_password + ├── superadmin + ├── cert_crt # (if HTTPS enabled) + └── cert_key +``` -**Example:** +**Examples:** +```bash +# Docker Compose deployment +osmanage setup ./my.instance.dir.org \ + --config config.yml \ + --template docker-compose.yml + +# Kubernetes deployment +osmanage setup ./my.instance.dir.org \ + --config k8s-config.yml \ + --template k8s-template-dir + +# Multiple config files (merged, later files override earlier) +osmanage setup ./my.instance.dir.org \ + --config base-config.yml \ + --config prod-overrides.yml \ + --template k8s-template-dir + +# Overwrite existing instance +osmanage setup ./my.instance.dir.org \ + --config config.yml \ + --template k8s-template-dir \ + --force +``` + +--- + +#### `config` + +(Re)creates deployment configuration files from templates and YAML config files. + +**Usage:** ```bash -osmanage setup ./deployment \ +osmanage config [flags] +``` + +**Flags:** +- `-t, --template `: Template file or directory (required) +- `-c, --config `: YAML config file(s) (can be used multiple times, required) +- `-f, --force`: Overwrite existing files + +**Behavior:** +- Merges multiple YAML config files (later files override earlier ones) +- Renders templates with merged configuration +- Creates or overwrites deployment files in the instance directory + +**Use Cases:** +- Regenerate deployment files after config changes +- Update templates without recreating secrets +- Apply new configuration to existing instance +- Fix or modify deployment manifests + +**Examples:** +```bash +# Regenerate deployment files +osmanage config ./my.instance.dir.org \ + --template ./k8s-templates \ + --config ./config.yml + +# Update with multiple configs (merged) +osmanage config ./my.instance.dir.org \ + --template ./k8s-templates \ + --config base-config.yml \ + --config prod-overrides.yml + +# Force overwrite existing files +osmanage config ./my.instance.dir.org \ + --template docker-compose.yml \ --config config.yml \ - --template templates/docker-compose.yml + --force ``` -### `action` +**Note:** This command does NOT regenerate secrets - it only (re)creates deployment files. Use `osmanage setup` for initial instance creation with secrets, or `osmanage create` to update passwords. + +--- + +#### `create` + +Updates an existing instance with new passwords. + +**Usage:** +```bash +osmanage create [flags] +``` + +**Flags:** +- `--db-password `: Set PostgreSQL password (required) +- `--superadmin-password `: Set superadmin password (required) + +**Use Cases:** +- Set specific passwords instead of random ones +- Rotate passwords for security +- Fix incorrect secret file permissions + +**Examples:** +```bash +# Set specific passwords +osmanage create ./my.instance.dir.org \ + --db-password "MySecureDBPassword123" \ + --superadmin-password "AdminPassword456" + +# Password rotation +osmanage create ./my.instance.dir.org \ + --db-password "$NEW_DB_PASS" \ + --superadmin-password "$NEW_ADMIN_PASS" +``` + +--- + +#### `remove` + +Deletes an instance directory and all its contents. + +**Usage:** +```bash +osmanage remove [flags] +``` + +**Flags:** +- `-f, --force`: Skip confirmation prompt + +**Warning:** This permanently deletes all files in the instance directory, including secrets and manifests. + +**Examples:** +```bash +# With confirmation prompt +osmanage remove ./my.instance.dir.org + +# Skip confirmation +osmanage remove ./my.instance.dir.org --force +``` + +--- + +### Kubernetes Operations + +Commands for managing OpenSlides instances in Kubernetes. + +**Requirements:** +- Valid kubeconfig file with cluster access (typically `~/.kube/config`) + - Or running inside a Kubernetes cluster with service account permissions +- Sufficient Kubernetes RBAC permissions to create/manage namespaces and resources + +**Note:** `osmanage` uses the Kubernetes Go client library and does **not** require `kubectl` to be installed. + +--- + +#### `k8s start` + +Deploys an OpenSlides instance to Kubernetes. + +**Usage:** +```bash +osmanage k8s start [flags] +``` + +**Flags:** +- `--kubeconfig `: Path to kubeconfig file (optional) +- `--skip-ready-check`: Skip waiting for instance to become ready +- `--timeout `: Maximum time to wait for deployment (default: 3m) + +**Features:** +- Creates dedicated namespace from namespace.yaml +- Creates secrets from instance secrets/ directory (base64-encoded) +- Applies all Kubernetes manifests from stack/ directory +- Shows progress bars for deployment readiness +- Waits for all pods to be healthy + +**Examples:** +```bash +# Standard deployment +osmanage k8s start ./my.instance.dir.org + +# Custom timeout +osmanage k8s start ./my.instance.dir.org --timeout 5m + +# Skip health check +osmanage k8s start ./my.instance.dir.org --skip-ready-check +``` + +**Output:** +``` +Applying manifest: my.instance.dir.org/namespace.yaml +Applied namespace: myinstancedirorg +Applying stack manifests from: my.instance.dir.org/stack/ +... + +Waiting for instance to become ready: +[████████████████████████████████████████] Pods ready (13/13) + +Instance is healthy: 13/13 pods ready +Instance started successfully +``` + +--- + +#### `k8s stop` + +Stops and removes an OpenSlides instance from Kubernetes. + +**Usage:** +```bash +osmanage k8s stop [flags] +``` + +**Flags:** +- `--kubeconfig `: Path to kubeconfig file (optional) +- `--timeout `: Maximum time to wait for deletion (default: 5m) + +**Behavior:** +- Saves TLS certificate secret (if exists) to `secrets/tls-letsencrypt-secret.yaml` +- Deletes the namespace and all resources + +**Warning:** This deletes the namespace and all resources, including persistent volumes. + +**Examples:** +```bash +# Stop instance +osmanage k8s stop ./my.instance.dir.org + +# Custom timeout +osmanage k8s stop ./my.instance.dir.org --timeout 10m +``` + +--- + +#### `k8s update-instance` + +Updates an existing Kubernetes instance with new manifests. + +**Usage:** +```bash +osmanage k8s update-instance [flags] +``` + +**Flags:** +- `--kubeconfig `: Path to kubeconfig file (optional) +- `--skip-ready-check`: Skip waiting for instance to become ready +- `--timeout `: Maximum time to wait for rollout (default: 3m) + +**Use Cases:** +- Apply configuration changes +- Update resource limits +- Modify service definitions +- Change replica counts + +**Examples:** +```bash +# Update after config changes +osmanage k8s update-instance ./my.instance.dir.org + +# Update with custom timeout +osmanage k8s update-instance ./my.instance.dir.org --timeout 5m + +# Skip health check +osmanage k8s update-instance ./my.instance.dir.org --skip-ready-check +``` + +--- + +#### `k8s update-backendmanage` + +Updates the backendmanage container image. + +**Usage:** +```bash +osmanage k8s update-backendmanage [flags] +``` + +**Flags (Required):** +- `--tag `: OpenSlides version tag (required) +- `--container-registry `: Container registry (required) + +**Flags (Optional):** +- `--kubeconfig `: Path to kubeconfig file +- `--timeout `: Maximum time to wait for rollout (default: 3m) +- `--revert`: Revert to previous image (uses tag and registry as revert target) + +**Examples:** +```bash +# Update to specific version +osmanage k8s update-backendmanage ./my.instance.dir.org \ + --tag 4.2.0 \ + --container-registry myregistry + +# Update to latest +osmanage k8s update-backendmanage ./my.instance.dir.org \ + --tag latest \ + --container-registry myregistry + +# Revert to previous version +osmanage k8s update-backendmanage ./my.instance.dir.org \ + --tag 4.1.9 \ + --container-registry myregistry + --revert + +# Custom timeout +osmanage k8s update-backendmanage ./my.instance.dir.org \ + --tag 4.2.1 \ + --container-registry myregistry + --timeout 5m +``` + +--- + +#### `k8s scale` + +Scales a specific service deployment. + +**Usage:** +```bash +osmanage k8s scale [flags] +``` + +**Flags (Required):** +- `--service `: Service deployment to scale (required) + +**Flags (Optional):** +- `--kubeconfig `: Path to kubeconfig file +- `--skip-ready-check`: Skip waiting for deployment to become ready +- `--timeout `: Maximum time to wait for scaling (default: 3m) + +**Note:** You must edit the deployment manifest file (`stack/-deployment.yaml`) to change replica count before running this command. + +**Examples:** +```bash +# Scale backend deployment (after editing manifest) +osmanage k8s scale ./my.instance.dir.org --service backend + +# Scale autoupdate without health check +osmanage k8s scale ./my.instance.dir.org --service autoupdate --skip-ready-check + +# Scale with custom timeout +osmanage k8s scale ./my.instance.dir.org --service backend --timeout 5m +``` + +--- + +#### `k8s health` + +Checks the health status of an OpenSlides instance. + +**Usage:** +```bash +osmanage k8s health [flags] +``` + +**Flags:** +- `--kubeconfig `: Path to kubeconfig file (optional) +- `--wait`: Wait for instance to become healthy +- `--timeout `: Timeout for health check (default: 3m, only with --wait) + +**Features:** +- Reports pod status for all deployments +- Shows ready/total pod counts +- Indicates overall instance health + +**Example:** +```bash +# Check current health +osmanage k8s health ./my.instance.dir.org + +# Wait for instance to become healthy +osmanage k8s health ./my.instance.dir.org --wait --timeout 5m +``` + +**Output:** +``` +Namespace: myinstancedirorg +Ready: 13/13 pods + +Pod Status: + ✓ auth-abc123 Running + ✓ autoupdate-def456 Running + ✓ backendaction-ghi789 Running + ✓ backendmanage-jkl012 Running + ✓ backendpresenter-mno345 Running + ✓ client-pqr678 Running + ✓ datastorereader-stu901 Running + ✓ datastorewriter-vwx234 Running + ✓ icc-yza567 Running + ✓ media-bcd890 Running + ✓ redis-efg123 Running + ✓ search-hij456 Running + ✓ vote-klm789 Running +``` + +--- + +#### `k8s cluster-status` + +Displays comprehensive cluster status. + +**Usage:** +```bash +osmanage k8s cluster-status [flags] +``` + +**Flags:** +- `--kubeconfig `: Path to kubeconfig file (optional) + +**Features:** +- Shows cluster-wide node health +- Reports ready vs total nodes + +**Example:** +```bash +osmanage k8s cluster-status +``` + +**Output:** +``` +cluster_status: 3 3 + +Total nodes: 3 +Ready nodes: 3 +Node node1: Ready +Node node2: Ready +Node node3: Ready +Cluster is healthy +``` + +--- + +### Backend Actions + +Commands for interacting with the OpenSlides backend API. + +**Note:** All backend action commands require `--address` and `--password-file` flags. + +--- + +#### `action` Execute arbitrary OpenSlides backend actions. @@ -150,26 +602,42 @@ Execute arbitrary OpenSlides backend actions. osmanage action [payload] [flags] ``` -**Flags:** -- `-a, --address`: Backend service address (default: `localhost:9002`) -- `--password-file`: Authorization password file (default: `secrets/internal_auth_password`) -- `-f, --file`: JSON payload file or `-` for stdin +**Flags (Required):** +- `-a, --address `: Backend service address (required) +- `--password-file `: Authorization password file (required) + +**Flags (Optional):** +- `-f, --file `: JSON payload file or `-` for stdin **Examples:** ```bash -# Inline JSON -osmanage action meeting.create '[{"name": "Annual Meeting", "committee_id": 1, "language": "de", "admin_ids": [1]}]' +# Docker Compose (localhost) +osmanage action meeting.create '[{"name": "Annual Meeting", "committee_id": 1, "language": "de", "admin_ids": [1]}]' \ + --address localhost:9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password + +# Kubernetes (port-forwarded) +kubectl port-forward -n myinstancedirorg svc/backendmanage 9002:9002 & +osmanage action meeting.create '[{"name": "Board Meeting", "committee_id": 1, "language": "de", "admin_ids": [1]}]' \ + --address localhost:9002 \ + --password-file ./my.instance.dir.org/secrets/internal_auth_password # From file -osmanage action meeting.create --file meeting.json +osmanage action meeting.create \ + --file meeting.json \ + --address localhost:9002 \ + --password-file ./secrets/internal_auth_password # From stdin -echo '[{"name": "Test Meeting", "committee_id": 1, "language": "de", "admin_ids": [1]}]' | osmanage action meeting.create --file - +echo '[{"name": "Test", "committee_id": 1, "language": "de", "admin_ids": [1]}]' | \ + osmanage action meeting.create --file - \ + --address localhost:9002 \ + --password-file ./secrets/internal_auth_password ``` --- -### `create-user` +#### `create-user` Create a new OpenSlides user. @@ -178,22 +646,29 @@ Create a new OpenSlides user. osmanage create-user [user-data] [flags] ``` -**Flags:** -- `-a, --address`: Backend service address (default: `localhost:9002`) -- `--password-file`: Authorization password file (default: `secrets/internal_auth_password`) -- `-f, --file`: JSON user data file or `-` for stdin +**Flags (Required):** +- `-a, --address `: Backend service address (required) +- `--password-file `: Authorization password file (required) + +**Flags (Optional):** +- `-f, --file `: JSON user data file or `-` for stdin -**Required Fields:** +**Required JSON Fields:** - `username`: User login name - `default_password`: Initial password -**Example:** +**Examples:** ```bash -# From file -osmanage create-user --file user.json +# Inline JSON +osmanage create-user '{"username": "admin", "default_password": "secret123"}' \ + --address localhost:9002 \ + --password-file ./secrets/internal_auth_password -# Inline -osmanage create-user '{"username": "admin", "default_password": "secret123", "first_name": "Admin", "last_name": "User"}' +# From file +osmanage create-user \ + --file user.json \ + --address localhost:9002 \ + --password-file ./secrets/internal_auth_password ``` **user.json:** @@ -210,7 +685,7 @@ osmanage create-user '{"username": "admin", "default_password": "secret123", "fi --- -### `get` +#### `get` Query the OpenSlides datastore with advanced filtering. @@ -224,16 +699,18 @@ osmanage get [flags] - `meeting` - `organization` -**Flags:** -- `--postgres-host`: PostgreSQL host (default: `localhost`) -- `--postgres-port`: PostgreSQL port (default: `5432`) -- `--postgres-user`: PostgreSQL user (default: `instance_user`) -- `--postgres-database`: PostgreSQL database (default: `instance_db`) -- `--postgres-password-file`: Password file (default: `/secrets/postgres-password`) -- `--fields`: Comma-separated field list -- `--filter`: Simple key=value filters (multiple allowed, AND'ed together) -- `--filter-raw`: Complex JSON filter with operators -- `--exists`: Return boolean (requires filter) +**Flags (Required):** +- `--postgres-host `: PostgreSQL host (required) +- `--postgres-port `: PostgreSQL port (required) +- `--postgres-user `: PostgreSQL user (required) +- `--postgres-database `: PostgreSQL database (required) +- `--postgres-password-file `: PostgreSQL password file (required) + +**Flags (Optional):** +- `--fields `: Comma-separated field list +- `--filter `: Simple equality filters (can be used multiple times, AND'ed together) +- `--filter-raw `: Complex JSON filter with operators +- `--exists`: Return boolean instead of data (requires filter) **Supported Operators (in `--filter-raw`):** - `=`: Equal @@ -246,63 +723,54 @@ osmanage get [flags] **Examples:** -**Simple queries:** +**Docker Compose:** ```bash -# Get all users with specific fields -osmanage get user --fields first_name,last_name,email - -# Filter by equality -osmanage get user --filter is_active=true --filter username=admin - -# Check existence -osmanage get meeting --filter id=1 --exists +# Simple query +osmanage get user --fields first_name,last_name,email \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user openslides \ + --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password + +# With filter +osmanage get user --filter is_active=true \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user openslides \ + --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password ``` **Complex filters:** ```bash -# Numeric comparison -osmanage get user --filter-raw '{"field":"id","operator":">","value":10}' - # Regex matching -osmanage get user --filter-raw '{"field":"username","operator":"~=","value":"^admin"}' +osmanage get user \ + --filter-raw '{"field":"username","operator":"~=","value":"^admin"}' \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user openslides \ + --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password # AND filter -osmanage get user --filter-raw '{ - "and_filter": [ - {"field": "is_active", "operator": "=", "value": true}, - {"field": "first_name", "operator": "~=", "value": "^M"} - ] -}' - -# OR filter -osmanage get meeting --filter-raw '{ - "or_filter": [ - {"field": "name", "operator": "~=", "value": "Annual"}, - {"field": "name", "operator": "~=", "value": "Board"} - ] -}' - -# NOT filter -osmanage get user --filter-raw '{ - "not_filter": { - "field": "is_active", - "operator": "=", - "value": false - } -}' - -# Nested filters -osmanage get user --filter-raw '{ - "and_filter": [ - {"field": "is_active", "operator": "=", "value": true}, - { - "or_filter": [ - {"field": "username", "operator": "~=", "value": "^admin"}, - {"field": "username", "operator": "~=", "value": "^super"} - ] - } - ] -}' +osmanage get user \ + --filter-raw '{"and_filter":[{"field":"is_active","operator":"=","value":true},{"field":"first_name","operator":"~=","value":"^M"}]}' \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user openslides \ + --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password + +# Check existence +osmanage get meeting \ + --filter id=1 \ + --exists \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user openslides \ + --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password ``` **Output Format:** @@ -327,7 +795,7 @@ osmanage get user --filter-raw '{ --- -### `initial-data` +#### `initial-data` Initialize a new OpenSlides datastore. @@ -336,81 +804,71 @@ Initialize a new OpenSlides datastore. osmanage initial-data [flags] ``` -**Flags:** -- `-a, --address`: Backend service address (default: `localhost:9002`) -- `--password-file`: Authorization password file (default: `secrets/internal_auth_password`) -- `--superadmin-password-file`: Superadmin password file (default: `secrets/superadmin`) -- `-f, --file`: JSON initial data file or `-` for stdin +**Flags (Required):** +- `-a, --address `: Backend service address (required) +- `--password-file `: Authorization password file (required) +- `--superadmin-password-file `: Superadmin password file (required) + +**Flags (Optional):** +- `-f, --file `: JSON initial data file or `-` for stdin **Behavior:** - Sets up organization and default data - Sets superadmin (user ID 1) password -- **Returns error if datastore is not empty** (exit code 2) +- Returns error if datastore is not empty (exit code 2) -**Example:** +**Examples:** ```bash -# With default data +# Docker Compose osmanage initial-data \ --address localhost:9002 \ - --password-file secrets/internal_auth_password \ - --superadmin-password-file secrets/superadmin - -# With custom initial data -osmanage initial-data \ - --address localhost:9002 \ - --password-file secrets/internal_auth_password \ - --superadmin-password-file secrets/superadmin \ - --file initial-data.json + --password-file ./secrets/internal_auth_password \ + --superadmin-password-file ./secrets/superadmin ``` --- -### `migrations` +#### `migrations` Manage OpenSlides database migrations. **Subcommands:** - `migrate`: Prepare migrations (dry-run) -- `finalize`: Prepare and apply migrations +- `finalize`: Apply migrations to datastore - `reset`: Reset unapplied migrations - `clear-collectionfield-tables`: Clear auxiliary tables (offline only) - `stats`: Show migration statistics -- `progress`: Query progress of running migration +- `progress`: Check running migration progress -**Common Flags:** -- `-a, --address`: Backend service address (default: `localhost:9002`) -- `--password-file`: Authorization password file (default: `secrets/internal_auth_password`) -- `--interval`: Progress polling interval (default: `1s`, use `0` to disable) +**Flags (Required):** +- `-a, --address `: Backend service address (required) +- `--password-file `: Authorization password file (required) -**Features:** -- Automatic retry with exponential backoff -- Real-time progress tracking -- Context-aware timeouts -- Network error handling +**Flags (Optional):** +- `--interval `: Progress check interval (default: `1s`, use `0` to disable, only for migrate/finalize) **Examples:** ```bash +# Check migration status +osmanage migrations stats \ + --address localhost:9002 \ + --password-file ./secrets/internal_auth_password + # Prepare migrations (dry-run) -osmanage migrations migrate --address localhost:9002 +osmanage migrations migrate \ + --address localhost:9002 \ + --password-file ./secrets/internal_auth_password -# Apply migrations with progress +# Apply migrations osmanage migrations finalize \ --address localhost:9002 \ - --password-file secrets/internal_auth_password + --password-file ./secrets/internal_auth_password -# Apply migrations without progress output +# Apply without progress output osmanage migrations finalize \ --address localhost:9002 \ + --password-file ./secrets/internal_auth_password \ --interval 0 - -# Check migration status -osmanage migrations stats --address localhost:9002 - -# Monitor running migration -osmanage migrations progress --address localhost:9002 - -# Reset migrations -osmanage migrations reset --address localhost:9002 ``` **Migration Stats Output:** @@ -426,7 +884,7 @@ status: migration_running --- -### `set` +#### `set` Update OpenSlides objects using backend actions. @@ -436,38 +894,32 @@ osmanage set [payload] [flags] ``` **Supported Actions:** -- `agenda_item` -- `committee` -- `group` -- `meeting` -- `motion` -- `organization` -- `organization_tag` -- `projector` -- `theme` -- `topic` -- `user` +- `agenda_item`, `committee`, `group`, `meeting`, `motion`, `organization`, `organization_tag`, `projector`, `theme`, `topic`, `user` -**Flags:** -- `-a, --address`: Backend service address (default: `localhost:9002`) -- `--password-file`: Authorization password file (default: `secrets/internal_auth_password`) -- `-f, --file`: JSON payload file or `-` for stdin +**Flags (Required):** +- `-a, --address `: Backend service address (required) +- `--password-file `: Authorization password file (required) + +**Flags (Optional):** +- `-f, --file `: JSON payload file or `-` for stdin **Examples:** ```bash # Update user -osmanage set user '[{"id": 5, "first_name": "Jane", "last_name": "Smith"}]' - -# Update meeting from file -osmanage set meeting --file meeting-update.json +osmanage set user '[{"id": 5, "first_name": "Jane", "last_name": "Smith"}]' \ + --address localhost:9002 \ + --password-file ./secrets/internal_auth_password -# Update organization -osmanage set organization '[{"id": 1, "name": "Updated Organization Name"}]' +# Update from file +osmanage set meeting \ + --file meeting-update.json \ + --address localhost:9002 \ + --password-file ./secrets/internal_auth_password ``` --- -### `set-password` +#### `set-password` Change a user's password. @@ -476,16 +928,17 @@ Change a user's password. osmanage set-password [flags] ``` -**Flags:** -- `-a, --address`: Backend service address (default: `localhost:9002`) -- `--password-file`: Authorization password file (default: `secrets/internal_auth_password`) -- `-u, --user_id`: User ID (required) -- `-p, --password`: New password (required) +**Flags (Required):** +- `-a, --address `: Backend service address (required) +- `--password-file `: Authorization password file (required) +- `-u, --user_id `: User ID (required) +- `-p, --password `: New password (required) **Example:** ```bash osmanage set-password \ --address localhost:9002 \ + --password-file ./secrets/internal_auth_password \ --user_id 5 \ --password "newSecurePassword123" ``` @@ -496,119 +949,191 @@ osmanage set-password \ ### Logging Levels -Set via the `--log-level` flag (applies to all commands): - +Control verbosity with the global `--log-level` flag: ```bash -osmanage --log-level debug get user +osmanage --log-level debug k8s start ./my.instance.dir.org ``` **Available levels:** - `debug`: Detailed diagnostic information -- `info`: General informational messages (default) -- `warn`: Warning messages +- `info`: General informational messages +- `warn`: Warning messages only (default) - `error`: Error messages only **Example output:** ``` -[INFO] === GET COLLECTION === -[DEBUG] Collection: user -[DEBUG] Found 150 total users -[DEBUG] Fields to fetch: [id first_name last_name email is_active] -[INFO] Query completed successfully +[INFO] === K8S START === +[DEBUG] Namespace: myinstancedirorg +[INFO] Applying Kubernetes manifests... +[DEBUG] Applied manifest: namespace.yaml +[INFO] Waiting for instance to become ready... +[INFO] Instance started successfully ``` --- ## Examples -### Complete Setup Workflow - +### Complete Kubernetes Workflow ```bash -# 1. Generate deployment configuration -osmanage setup ./openslides-deployment \ - --config config.yml \ - --template templates/docker-compose.yml +# 1. Generate instance +osmanage setup ./prod.instance.org \ + --config prod-config.yml \ + --template k8s-template-dir -# 2. Start services (Docker Compose example) -cd openslides-deployment -docker-compose up -d +# 2. Customize secrets (optional) +osmanage create ./prod.instance.org \ + --db-password "$SECURE_DB_PASS" \ + --superadmin-password "$SECURE_ADMIN_PASS" -# 3. Access OpenSlides -# Visit http://localhost:8000 -# Login as 'superadmin' with password from: -cat secrets/superadmin +# 3. Deploy to Kubernetes +osmanage k8s start ./prod.instance.org + +# 4. Check health +osmanage k8s health ./prod.instance.org + +# 5. Scale backend deployment (after editing manifest) +osmanage k8s scale ./prod.instance.org --service projector + +# 6. Update backend image +osmanage k8s update-backendmanage ./prod.instance.org \ + --tag 4.2.1 \ + --container-registry myregistry + +# 7. Stop instance +osmanage k8s stop ./prod.instance.org ``` -### Backup User Data +--- +### Backup User Data ```bash -# Export all users to JSON -osmanage get user > backup-users-$(date +%Y%m%d).json - -# Export specific fields only +# Export all users +osmanage get user \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user instance_user \ + --postgres-database instance_db \ + --postgres-password-file ./my.instance.dir.org/secrets/postgres_password \ + > backup-users-$(date +%Y%m%d).json + +# Export specific fields osmanage get user \ --fields username,first_name,last_name,email \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user instance_user \ + --postgres-database instance_db \ + --postgres-password-file ./my.instance.dir.org/secrets/postgres_password \ > backup-users-minimal.json ``` +--- + ### Query Active Meetings +```bash +# Get all active meetings with details +osmanage get meeting \ + --filter is_active_in_organization_id=1 \ + --fields name,start_time,end_time,location \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user openslides \ + --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password + +# Count active meetings +osmanage get meeting \ + --filter is_active_in_organization_id=1 \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user openslides \ + --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password \ + | jq 'length' + +# Check if specific meeting exists and is active +osmanage get meeting \ + --filter-raw '{"and_filter":[{"field":"id","operator":"=","value":1},{"field":"is_active_in_organization_id","operator":"=","value":1}]}' \ + --exists \ + --postgres-host localhost \ + --postgres-port 5432 \ + --postgres-user openslides \ + --postgres-database openslides \ + --postgres-password-file ./secrets/postgres_password +``` - ```bash - # Get all active meetings with details - osmanage get meeting \ - --filter is_active_in_organization_id=1 \ - --fields name,start_time,end_time,location - - # Count active meetings - osmanage get meeting \ - --filter is_active_in_organization_id=1 \ - | jq 'length' - - # See if an active meeting exists by id - osmanage get meeting \ - --filter-raw '{"and_filter": [{"field": "id", "operator": "=", "value": 1}, {"field": "is_active_in_organization_id", "operator": "=", "value": 1}]}' - --exists - # easier (id=5) - osmanage get meeting --filter is_active_in_organization=1 --fields name | jq '. "5"' - ``` +--- ## Development ### Project Structure - ``` openslides-cli/ ├── cmd/ -│ └── osmanage/ # Main entry point -│ └── main.go +│ └── osmanage/ # Main entry point +│ ├── main.go +│ └── main_test.go ├── internal/ -│ ├── actions/ # Action commands -│ │ ├── action/ -│ │ │ └── action.go -│ │ ├── createuser/ -│ │ │ └── createuser.go -│ │ ├── get/ -│ │ │ ├── get.go -│ │ │ └── get_test.go -│ │ ├── initialdata/ -│ │ │ └── initialdata.go -│ │ ├── set/ -│ │ │ └── set.go -│ │ └── setpassword/ -│ │ └── setpassword.go -│ ├── client/ # HTTP client -│ │ ├── client.go -│ │ └── client_test.go -│ ├── logger/ # Logging -│ │ ├── logger.go -│ │ └── logger_test.go -│ ├── migrations/ # Migration commands -│ │ ├── migrations.go -│ │ └── migrations_test.go -│ ├── templating/ # Setup & templating +│ ├── constants/ # Project-wide constants +│ │ └── constants.go +│ ├── instance/ # Instance management │ │ ├── config/ +│ │ │ ├── config.go +│ │ │ └── config_test.go +│ │ ├── create/ +│ │ │ ├── create.go +│ │ │ └── create_test.go +│ │ ├── remove/ +│ │ │ ├── remove.go +│ │ │ └── remove_test.go │ │ └── setup/ -│ └── utils/ # Utilities +│ │ ├── setup.go +│ │ └── setup_test.go +│ ├── k8s/ # Kubernetes operations +│ │ ├── actions/ +│ │ │ ├── apply.go +│ │ │ ├── cluster_status.go +│ │ │ ├── cluster_status_test.go +│ │ │ ├── health.go +│ │ │ ├── health_check.go +│ │ │ ├── health_check_test.go +│ │ │ ├── helpers.go +│ │ │ ├── helpers_test.go +│ │ │ ├── scale.go +│ │ │ ├── start.go +│ │ │ ├── stop.go +│ │ │ ├── update_backendmanage.go +│ │ │ └── update_instance.go +│ │ └── client/ +│ │ └── client.go +│ ├── manage/ # Backend action commands +│ │ ├── actions/ +│ │ │ ├── action/ +│ │ │ │ └── action.go +│ │ │ ├── createuser/ +│ │ │ │ └── createuser.go +│ │ │ ├── get/ +│ │ │ │ ├── get.go +│ │ │ │ └── get_test.go +│ │ │ ├── initialdata/ +│ │ │ │ └── initialdata.go +│ │ │ ├── integration_test.go +│ │ │ ├── migrations/ +│ │ │ │ ├── migrations.go +│ │ │ │ └── migrations_test.go +│ │ │ ├── set/ +│ │ │ │ ├── set.go +│ │ │ │ └── set_test.go +│ │ │ └── setpassword/ +│ │ │ └── setpassword.go +│ │ └── client/ +│ │ ├── client.go +│ │ └── client_test.go +│ ├── logger/ # Logging utilities +│ │ ├── logger.go +│ │ └── logger_test.go +│ └── utils/ # Common utilities │ ├── utils.go │ └── utils_test.go ├── go.mod @@ -617,40 +1142,53 @@ openslides-cli/ └── LICENSE ``` -### Running Tests +--- +### Running Tests ```bash # Run all tests go test ./... -# Run tests with coverage +# Run with coverage go test -cover ./... + +# Run specific package +go test ./internal/k8s/actions + +# Verbose output +go test -v ./... ``` -### Building +--- +### Building ```bash -# Build for testing (bigger binary, debuggable) +# Development build (larger binary, debuggable) go build -o osmanage ./cmd/osmanage -# Build for prod (smaller binary, no C code, no debug) + +# Production build (smaller binary, optimized, no debug symbols) CGO_ENABLED=0 go build -a -ldflags="-s -w" -o osmanage ./cmd/osmanage ``` +--- + ### Contributing 1. Fork the repository 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 3. Write tests for your changes 4. Ensure all tests pass (`go test ./...`) -5. Commit your changes (`git commit -m 'Add amazing feature'`) -6. Push to the branch (`git push origin feature/amazing-feature`) -7. Open a Pull Request +5. Run `go fmt ./...` to format code +6. Commit your changes (`git commit -m 'Add amazing feature'`) +7. Push to the branch (`git push origin feature/amazing-feature`) +8. Open a Pull Request **Code Style:** - Follow standard Go conventions - Run `go fmt` before committing - Add tests for new functionality - Update documentation as needed +- Use project constants from `internal/constants` --- @@ -659,13 +1197,17 @@ CGO_ENABLED=0 go build -a -ldflags="-s -w" -o osmanage ./cmd/osmanage This tool represents a significant refactor and expansion of the original [openslides-manage-service](https://github.com/OpenSlides/openslides-manage-service) created by **Norman Jäckel**. **Major Changes from Original:** +- Complete Kubernetes orchestration system with health checks and progress tracking - Migration from `datastorereader` to `openslides-go/datastore/dsfetch` -- Removed gRPC -- Filtering in-memory (until better solution is found -> TODO) -- retry mechanism for migrations -- Comprehensive test coverage -- Improved deployment configuration system -- Simplified the templating +- Removed gRPC dependencies +- In-memory filtering for datastore queries +- Comprehensive retry mechanisms for migrations +- Extensive test coverage +- Improved deployment configuration and templating +- Centralized constants and project structure +- Instance management commands (setup/config/create/remove) +- Real-time deployment monitoring with progress bars +- Cluster status and health monitoring **Refactored/Developed by:** Alexej Antoni @ Intevation GmbH From 2d33079cf4a60ed7bc5214af27e8cf05b41ae2dd Mon Sep 17 00:00:00 2001 From: aantoni Date: Wed, 4 Feb 2026 19:08:18 +0100 Subject: [PATCH 27/28] make golangci linter fix --- cmd/osmanage/main_test.go | 1 + internal/k8s/actions/cluster_status_test.go | 2 ++ 2 files changed, 3 insertions(+) diff --git a/cmd/osmanage/main_test.go b/cmd/osmanage/main_test.go index 56de13b..5e112c1 100644 --- a/cmd/osmanage/main_test.go +++ b/cmd/osmanage/main_test.go @@ -19,6 +19,7 @@ func TestRootCmd(t *testing.T) { logLevelFlag := cmd.PersistentFlags().Lookup("log-level") if logLevelFlag == nil { t.Fatal("Expected log-level flag to exist") + return } if logLevelFlag.DefValue != "warn" { t.Errorf("Expected default log-level 'info', got %s", logLevelFlag.DefValue) diff --git a/internal/k8s/actions/cluster_status_test.go b/internal/k8s/actions/cluster_status_test.go index 44998fc..febf8e3 100644 --- a/internal/k8s/actions/cluster_status_test.go +++ b/internal/k8s/actions/cluster_status_test.go @@ -181,6 +181,7 @@ func TestGetNodeCondition_Exists(t *testing.T) { condition := GetNodeCondition(node, corev1.NodeReady) if condition == nil { t.Fatal("Expected to find Ready condition") + return } if condition.Type != corev1.NodeReady { @@ -240,6 +241,7 @@ func TestGetNodeCondition_MultipleConditions(t *testing.T) { condition := GetNodeCondition(node, corev1.NodeDiskPressure) if condition == nil { t.Fatal("Expected to find DiskPressure condition") + return } if condition.Type != corev1.NodeDiskPressure { From 2595d63e69b90ff838dbdc60cb710019a0ca16db Mon Sep 17 00:00:00 2001 From: aantoni Date: Thu, 5 Feb 2026 11:47:21 +0100 Subject: [PATCH 28/28] add less misleading config merge description --- README.md | 2 +- internal/instance/config/config.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 48e7dba..1eed834 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ osmanage config [flags] - `-f, --force`: Overwrite existing files **Behavior:** -- Merges multiple YAML config files (later files override earlier ones) +- Merges multiple YAML config files (later file's fields override earlier ones) - Renders templates with merged configuration - Creates or overwrites deployment files in the instance directory diff --git a/internal/instance/config/config.go b/internal/instance/config/config.go index 1f122cb..688ec53 100644 --- a/internal/instance/config/config.go +++ b/internal/instance/config/config.go @@ -27,7 +27,7 @@ const ( Generates deployment files (Docker Compose or Kubernetes manifests) using templates and YAML configuration files. Multiple config files are deep-merged -in order, with later files overriding earlier ones. +in order, with later file's fields overriding earlier ones. Template functions available: • marshalContent - Marshal YAML content with indentation