diff --git a/.gitignore b/.gitignore index 70c5d7e8f..428e142ad 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist/ # IDE .vscode +.idea # OS generated files .DS_Store diff --git a/docs/stackit_ske_kubeconfig.md b/docs/stackit_ske_kubeconfig.md index 5a32dfd23..d21edc87f 100644 --- a/docs/stackit_ske_kubeconfig.md +++ b/docs/stackit_ske_kubeconfig.md @@ -30,4 +30,5 @@ stackit ske kubeconfig [flags] * [stackit ske](./stackit_ske.md) - Provides functionality for SKE * [stackit ske kubeconfig create](./stackit_ske_kubeconfig_create.md) - Creates a kubeconfig for an SKE cluster +* [stackit ske kubeconfig login](./stackit_ske_kubeconfig_login.md) - Login plugin for kubernetes clients diff --git a/docs/stackit_ske_kubeconfig_create.md b/docs/stackit_ske_kubeconfig_create.md index 8e16e8738..a779cacc0 100644 --- a/docs/stackit_ske_kubeconfig_create.md +++ b/docs/stackit_ske_kubeconfig_create.md @@ -21,6 +21,9 @@ stackit ske kubeconfig create CLUSTER_NAME [flags] Create a kubeconfig for the SKE cluster with name "my-cluster" $ stackit ske kubeconfig create my-cluster + Get a login kubeconfig for the SKE cluster with name "my-cluster". This kubeconfig does not contain any credentials and instead obtains valid credentials via the `stackit ske kubeconfig login` command. + $ stackit ske kubeconfig create my-cluster --login + Create a kubeconfig for the SKE cluster with name "my-cluster" and set the expiration time to 30 days $ stackit ske kubeconfig create my-cluster --expiration 30d @@ -37,6 +40,7 @@ stackit ske kubeconfig create CLUSTER_NAME [flags] -e, --expiration string Expiration time for the kubeconfig in seconds(s), minutes(m), hours(h), days(d) or months(M). Example: 30d. By default, expiration time is 1h --filepath string Path to create the kubeconfig file. By default, the kubeconfig is created as 'config' in the .kube folder, in the user's home directory. -h, --help Help for "stackit ske kubeconfig create" + -l, --login Create a login kubeconfig that obtains valid credentials via the STACKIT CLI. This flag is mutually exclusive with the expiration flag. ``` ### Options inherited from parent commands diff --git a/docs/stackit_ske_kubeconfig_login.md b/docs/stackit_ske_kubeconfig_login.md new file mode 100644 index 000000000..d7e2d7691 --- /dev/null +++ b/docs/stackit_ske_kubeconfig_login.md @@ -0,0 +1,45 @@ +## stackit ske kubeconfig login + +Login plugin for kubernetes clients + +### Synopsis + +Login plugin for kubernetes clients, that creates short-lived credentials to authenticate against a STACKIT Kubernetes Engine (SKE) cluster. +First you need to obtain a kubeconfig for use with the login command (first example). +Secondly you use the kubeconfig with your chosen Kubernetes client (second example), the client will automatically retrieve the credentials via the STACKIT CLI. + +``` +stackit ske kubeconfig login [flags] +``` + +### Examples + +``` + Get a login kubeconfig for the SKE cluster with name "my-cluster". This kubeconfig does not contain any credentials and instead obtains valid credentials via the `stackit ske kubeconfig login` command. + $ stackit ske kubeconfig create my-cluster --login + + Use the previously saved kubeconfig to authenticate to the SKE cluster, in this case with kubectl. + $ kubectl cluster-info + $ kubectl get pods +``` + +### Options + +``` + -h, --help Help for "stackit ske kubeconfig login" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit ske kubeconfig](./stackit_ske_kubeconfig.md) - Provides functionality for SKE kubeconfig + diff --git a/go.mod b/go.mod index c8a5eabf2..dacd1fa85 100644 --- a/go.mod +++ b/go.mod @@ -23,12 +23,20 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.8.0 github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.7.0 github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.4.0 - github.com/stackitcloud/stackit-sdk-go/services/ske v0.14.0 + github.com/stackitcloud/stackit-sdk-go/services/ske v0.15.0 github.com/zalando/go-keyring v0.2.4 golang.org/x/mod v0.17.0 golang.org/x/oauth2 v0.20.0 golang.org/x/term v0.20.0 golang.org/x/text v0.15.0 + k8s.io/apimachinery v0.29.2 + k8s.io/client-go v0.29.2 +) + +require ( + golang.org/x/net v0.20.0 // indirect + golang.org/x/time v0.5.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect ) require ( @@ -41,17 +49,23 @@ require ( github.com/alessio/shellescape v1.4.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect github.com/danieljoos/wincred v1.2.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-logr/logr v1.3.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gofuzz v1.2.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/imdario/mergo v0.3.6 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.1.1 // indirect github.com/rivo/uniseg v0.4.4 // indirect - github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect @@ -69,7 +83,13 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect golang.org/x/sys v0.20.0 // indirect - gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.29.2 // indirect + k8s.io/klog/v2 v2.110.1 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect ) diff --git a/go.sum b/go.sum index ae6ae369f..88aca33d6 100644 --- a/go.sum +++ b/go.sum @@ -8,12 +8,22 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w= github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +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 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= @@ -24,24 +34,40 @@ github.com/goccy/go-yaml v1.11.3 h1:B3W9IdWbvrUu2OYQGwvU1nZtvMQJPBKgBUuweJjLj6I= github.com/goccy/go-yaml v1.11.3/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 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/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf h1:FtEj8sfIcaaBfAKrE1Cwb61YDtYq9JxChK1c7AKce7s= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf/go.mod h1:yrqSXGoD/4EKfF26AOGzscPOgTTJcyAwM2rpixWT+t4= github.com/jedib0t/go-pretty/v6 v6.5.9 h1:ACteMBRrrmm1gMsXe9PSTOClQ63IXDUt03H5U+UV8OU= github.com/jedib0t/go-pretty/v6 v6.5.9/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E= +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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 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/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= @@ -50,6 +76,8 @@ github.com/lmittmann/tint v1.0.4 h1:LeYihpJ9hyGvE0w+K2okPTGUdVLfng1+nDNVR4vWISc= github.com/lmittmann/tint v1.0.4/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -59,8 +87,13 @@ github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZ github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/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/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -121,44 +154,101 @@ github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.7.0 h1:1Ho+M4D github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.7.0/go.mod h1:LX0Mcyr7/QP77zf7e05fHCJO38RMuTxr7nEDUDZ3oPQ= github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.4.0 h1:JB1O0E9+L50ZaO36uz7azurvUuB5JdX5s2ZXuIdb9t8= github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.4.0/go.mod h1:Ni9RBJvcaXRIrDIuQBpJcuQvCQSj27crQSyc+WM4p0c= -github.com/stackitcloud/stackit-sdk-go/services/ske v0.14.0 h1:GH67aTvjXiXC2XmYhgmqNXfG13JHKB3wsk5JlTErsjg= -github.com/stackitcloud/stackit-sdk-go/services/ske v0.14.0/go.mod h1:0fFs4R7kg+gU7FNAIzzFvlCZJz6gyZ8CFhbK3eSrAwQ= +github.com/stackitcloud/stackit-sdk-go/services/ske v0.15.0 h1:7iTzdiglvJmKMaHlr4JUPvNOmA730rAniry74cnZ8zI= +github.com/stackitcloud/stackit-sdk-go/services/ske v0.15.0/go.mod h1:0fFs4R7kg+gU7FNAIzzFvlCZJz6gyZ8CFhbK3eSrAwQ= 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 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68= github.com/zalando/go-keyring v0.2.4/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= -golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/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/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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.29.2 h1:hBC7B9+MU+ptchxEqTNW2DkUosJpp1P+Wn6YncZ474A= +k8s.io/api v0.29.2/go.mod h1:sdIaaKuU7P44aoyyLlikSLayT6Vb7bvJNCX105xZXY0= +k8s.io/apimachinery v0.29.2 h1:EWGpfJ856oj11C52NRCHuU7rFDwxev48z+6DSlGNsV8= +k8s.io/apimachinery v0.29.2/go.mod h1:6HVkd1FwxIagpYrHSwJlQqZI3G9LfYWRPAkUvLnXTKU= +k8s.io/client-go v0.29.2 h1:FEg85el1TeZp+/vYJM7hkDlSTFZ+c5nnK44DJ4FyoRg= +k8s.io/client-go v0.29.2/go.mod h1:knlvFZE58VpqbQpJNbCbctTVXcd35mMyAAwBdpt4jrA= +k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0= +k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= +k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780= +k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/internal/cmd/ske/kubeconfig/create/create.go b/internal/cmd/ske/kubeconfig/create/create.go index d53513eeb..47309897d 100644 --- a/internal/cmd/ske/kubeconfig/create/create.go +++ b/internal/cmd/ske/kubeconfig/create/create.go @@ -22,6 +22,7 @@ import ( const ( clusterNameArg = "CLUSTER_NAME" + loginFlag = "login" expirationFlag = "expiration" filepathFlag = "filepath" ) @@ -31,6 +32,7 @@ type inputModel struct { ClusterName string Filepath *string ExpirationTime *string + Login bool } func NewCmd(p *print.Printer) *cobra.Command { @@ -48,6 +50,10 @@ func NewCmd(p *print.Printer) *cobra.Command { examples.NewExample( `Create a kubeconfig for the SKE cluster with name "my-cluster"`, "$ stackit ske kubeconfig create my-cluster"), + examples.NewExample( + `Get a login kubeconfig for the SKE cluster with name "my-cluster". `+ + "This kubeconfig does not contain any credentials and instead obtains valid credentials via the `stackit ske kubeconfig login` command.", + "$ stackit ske kubeconfig create my-cluster --login"), examples.NewExample( `Create a kubeconfig for the SKE cluster with name "my-cluster" and set the expiration time to 30 days`, "$ stackit ske kubeconfig create my-cluster --expiration 30d"), @@ -80,20 +86,41 @@ func NewCmd(p *print.Printer) *cobra.Command { } // Call API - req, err := buildRequest(ctx, model, apiClient) - if err != nil { - return fmt.Errorf("build kubeconfig create request: %w", err) - } - resp, err := req.Execute() - if err != nil { - return fmt.Errorf("create kubeconfig for SKE cluster: %w", err) + var ( + kubeconfig string + respKubeconfig *ske.Kubeconfig + respLogin *ske.V1LoginKubeconfig + ) + + if !model.Login { + req, err := buildRequestCreate(ctx, model, apiClient) + if err != nil { + return fmt.Errorf("build kubeconfig create request: %w", err) + } + respKubeconfig, err = req.Execute() + if err != nil { + return fmt.Errorf("create kubeconfig for SKE cluster: %w", err) + } + if respKubeconfig.Kubeconfig == nil { + return fmt.Errorf("no kubeconfig returned from the API") + } + kubeconfig = *respKubeconfig.Kubeconfig + } else { + req, err := buildRequestLogin(ctx, model, apiClient) + if err != nil { + return fmt.Errorf("build login kubeconfig create request: %w", err) + } + respLogin, err = req.Execute() + if err != nil { + return fmt.Errorf("create login kubeconfig for SKE cluster: %w", err) + } + if respLogin.Kubeconfig == nil { + return fmt.Errorf("no login kubeconfig returned from the API") + } + kubeconfig = *respLogin.Kubeconfig } // Create the config file - if resp.Kubeconfig == nil { - return fmt.Errorf("no kubeconfig returned from the API") - } - var kubeconfigPath string if model.Filepath == nil { kubeconfigPath, err = skeUtils.GetDefaultKubeconfigPath() @@ -104,12 +131,14 @@ func NewCmd(p *print.Printer) *cobra.Command { kubeconfigPath = *model.Filepath } - err = skeUtils.WriteConfigFile(kubeconfigPath, *resp.Kubeconfig) - if err != nil { - return fmt.Errorf("write kubeconfig file: %w", err) + if model.OutputFormat != print.JSONOutputFormat { + err = skeUtils.WriteConfigFile(kubeconfigPath, kubeconfig) + if err != nil { + return fmt.Errorf("write kubeconfig file: %w", err) + } } - return outputResult(p, model, kubeconfigPath, resp) + return outputResult(p, model, kubeconfigPath, respKubeconfig, respLogin) }, } configureFlags(cmd) @@ -117,8 +146,11 @@ func NewCmd(p *print.Printer) *cobra.Command { } func configureFlags(cmd *cobra.Command) { + cmd.Flags().BoolP(loginFlag, "l", false, "Create a login kubeconfig that obtains valid credentials via the STACKIT CLI. This flag is mutually exclusive with the expiration flag.") cmd.Flags().StringP(expirationFlag, "e", "", "Expiration time for the kubeconfig in seconds(s), minutes(m), hours(h), days(d) or months(M). Example: 30d. By default, expiration time is 1h") cmd.Flags().String(filepathFlag, "", "Path to create the kubeconfig file. By default, the kubeconfig is created as 'config' in the .kube folder, in the user's home directory.") + + cmd.MarkFlagsMutuallyExclusive(loginFlag, expirationFlag) } func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { @@ -147,6 +179,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ClusterName: clusterName, Filepath: flags.FlagToStringPointer(p, cmd, filepathFlag), ExpirationTime: expTime, + Login: flags.FlagToBoolValue(p, cmd, loginFlag), } if p.IsVerbosityDebug() { @@ -161,7 +194,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu return &model, nil } -func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) (ske.ApiCreateKubeconfigRequest, error) { +func buildRequestCreate(ctx context.Context, model *inputModel, apiClient *ske.APIClient) (ske.ApiCreateKubeconfigRequest, error) { req := apiClient.CreateKubeconfig(ctx, model.ProjectId, model.ClusterName) payload := ske.CreateKubeconfigPayload{} @@ -173,10 +206,20 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClie return req.CreateKubeconfigPayload(payload), nil } -func outputResult(p *print.Printer, model *inputModel, kubeconfigPath string, resp *ske.Kubeconfig) error { +func buildRequestLogin(ctx context.Context, model *inputModel, apiClient *ske.APIClient) (ske.ApiGetLoginKubeconfigRequest, error) { + return apiClient.GetLoginKubeconfig(ctx, model.ProjectId, model.ClusterName), nil +} + +func outputResult(p *print.Printer, model *inputModel, kubeconfigPath string, respKubeconfig *ske.Kubeconfig, respLogin *ske.V1LoginKubeconfig) error { switch model.OutputFormat { case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") + var err error + var details []byte + if respKubeconfig != nil { + details, err = json.MarshalIndent(respKubeconfig, "", " ") + } else if respLogin != nil { + details, err = json.MarshalIndent(respLogin, "", " ") + } if err != nil { return fmt.Errorf("marshal SKE Kubeconfig: %w", err) } @@ -184,7 +227,13 @@ func outputResult(p *print.Printer, model *inputModel, kubeconfigPath string, re return nil case print.YAMLOutputFormat: - details, err := yaml.Marshal(resp) + var err error + var details []byte + if respKubeconfig != nil { + details, err = yaml.Marshal(respKubeconfig) + } else if respLogin != nil { + details, err = yaml.Marshal(respLogin) + } if err != nil { return fmt.Errorf("marshal SKE Kubeconfig: %w", err) } @@ -192,7 +241,11 @@ func outputResult(p *print.Printer, model *inputModel, kubeconfigPath string, re return nil default: - p.Outputf("Created kubeconfig file for cluster %s in %q, with expiration date %v (UTC)\n", model.ClusterName, kubeconfigPath, *resp.ExpirationTimestamp) + var expiration string + if respKubeconfig != nil { + expiration = fmt.Sprintf(", with expiration date %v (UTC)", *respKubeconfig.ExpirationTimestamp) + } + p.Outputf("Created kubeconfig file for cluster %s in %q%s\n", model.ClusterName, kubeconfigPath, expiration) return nil } diff --git a/internal/cmd/ske/kubeconfig/create/create_test.go b/internal/cmd/ske/kubeconfig/create/create_test.go index 17b30a4f6..86fb29ac4 100644 --- a/internal/cmd/ske/kubeconfig/create/create_test.go +++ b/internal/cmd/ske/kubeconfig/create/create_test.go @@ -92,7 +92,17 @@ func TestParseInput(t *testing.T) { model.ExpirationTime = utils.Ptr("2592000") }), }, - + { + description: "login", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["login"] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Login = true + }), + }, { description: "custom filepath", argValues: fixtureArgValues(), @@ -202,7 +212,7 @@ func TestParseInput(t *testing.T) { } } -func TestBuildRequest(t *testing.T) { +func TestBuildRequestCreate(t *testing.T) { tests := []struct { description string model *inputModel @@ -225,7 +235,7 @@ func TestBuildRequest(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - request, _ := buildRequest(testCtx, tt.model, testClient) + request, _ := buildRequestCreate(testCtx, tt.model, testClient) diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), diff --git a/internal/cmd/ske/kubeconfig/kubeconfig.go b/internal/cmd/ske/kubeconfig/kubeconfig.go index 3370a8529..44803f14b 100644 --- a/internal/cmd/ske/kubeconfig/kubeconfig.go +++ b/internal/cmd/ske/kubeconfig/kubeconfig.go @@ -2,6 +2,7 @@ package kubeconfig import ( "github.com/stackitcloud/stackit-cli/internal/cmd/ske/kubeconfig/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/ske/kubeconfig/login" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -23,4 +24,5 @@ func NewCmd(p *print.Printer) *cobra.Command { func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand(create.NewCmd(p)) + cmd.AddCommand(login.NewCmd(p)) } diff --git a/internal/cmd/ske/kubeconfig/login/login.go b/internal/cmd/ske/kubeconfig/login/login.go new file mode 100644 index 000000000..56e9b9322 --- /dev/null +++ b/internal/cmd/ske/kubeconfig/login/login.go @@ -0,0 +1,253 @@ +package login + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "os" + "strconv" + "time" + + "github.com/stackitcloud/stackit-cli/internal/pkg/cache" + "k8s.io/client-go/rest" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1" + "k8s.io/client-go/tools/auth/exec" + "k8s.io/client-go/tools/clientcmd" +) + +const ( + expirationSeconds = 30 * 60 // 30 min + refreshBeforeDuration = 15 * time.Minute // 15 min +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "login", + Short: "Login plugin for kubernetes clients", + Long: fmt.Sprintf("%s\n%s\n%s", + "Login plugin for kubernetes clients, that creates short-lived credentials to authenticate against a STACKIT Kubernetes Engine (SKE) cluster.", + "First you need to obtain a kubeconfig for use with the login command (first example).", + "Secondly you use the kubeconfig with your chosen Kubernetes client (second example), the client will automatically retrieve the credentials via the STACKIT CLI.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Get a login kubeconfig for the SKE cluster with name "my-cluster". `+ + "This kubeconfig does not contain any credentials and instead obtains valid credentials via the `stackit ske kubeconfig login` command.", + "$ stackit ske kubeconfig create my-cluster --login"), + examples.NewExample( + "Use the previously saved kubeconfig to authenticate to the SKE cluster, in this case with kubectl.", + "$ kubectl cluster-info", + "$ kubectl get pods"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + if err := cache.Init(); err != nil { + return fmt.Errorf("cache init failed: %w", err) + } + + env := os.Getenv("KUBERNETES_EXEC_INFO") + if env == "" { + return fmt.Errorf("%s\n%s\n%s", "KUBERNETES_EXEC_INFO env var is unset or empty.", + "The command probably was not called from a Kubernetes client application!", + "See `stackit ske login --help` for detailed usage instructions.") + } + + clusterConfig, err := parseClusterConfig() + if err != nil { + return fmt.Errorf("parseClusterConfig: %w", err) + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + cachedKubeconfig := getCachedKubeConfig(clusterConfig.cacheKey) + + if cachedKubeconfig == nil { + return GetAndOutputKubeconfig(ctx, p, apiClient, clusterConfig, false, nil) + } + + certPem, _ := pem.Decode(cachedKubeconfig.CertData) + if certPem == nil { + _ = cache.DeleteObject(clusterConfig.cacheKey) + return GetAndOutputKubeconfig(ctx, p, apiClient, clusterConfig, false, nil) + } + + certificate, err := x509.ParseCertificate(certPem.Bytes) + if err != nil { + _ = cache.DeleteObject(clusterConfig.cacheKey) + return GetAndOutputKubeconfig(ctx, p, apiClient, clusterConfig, false, nil) + } + + // cert is expired, request new + if time.Now().After(certificate.NotAfter.UTC()) { + _ = cache.DeleteObject(clusterConfig.cacheKey) + return GetAndOutputKubeconfig(ctx, p, apiClient, clusterConfig, false, nil) + } + // cert expires within the next 15min, refresh (try to get a new, use cache on failure) + if time.Now().Add(refreshBeforeDuration).After(certificate.NotAfter.UTC()) { + return GetAndOutputKubeconfig(ctx, p, apiClient, clusterConfig, true, cachedKubeconfig) + } + + // cert not expired, nor will it expire in the next 15min; therefore, use the cached kubeconfig + if err := output(p, clusterConfig.cacheKey, cachedKubeconfig); err != nil { + return err + } + return nil + }, + } + return cmd +} + +type clusterConfig struct { + STACKITProjectID string `json:"stackitProjectId"` + ClusterName string `json:"clusterName"` + + cacheKey string +} + +func parseClusterConfig() (*clusterConfig, error) { + obj, _, err := exec.LoadExecCredentialFromEnv() + if err != nil { + return nil, fmt.Errorf("LoadExecCredentialFromEnv: %w", err) + } + + if err := clientauthenticationv1.AddToScheme(scheme.Scheme); err != nil { + return nil, err + } + + obj, err = scheme.Scheme.ConvertToVersion(obj, clientauthenticationv1.SchemeGroupVersion) + if err != nil { + return nil, fmt.Errorf("ConvertToVersion: %w", err) + } + + execCredential, ok := obj.(*clientauthenticationv1.ExecCredential) + if !ok { + return nil, fmt.Errorf("conversion to ExecCredential failed") + } + if execCredential == nil || execCredential.Spec.Cluster == nil { + return nil, fmt.Errorf("ExecCredential contains not all needed fields") + } + config := &clusterConfig{} + err = json.Unmarshal(execCredential.Spec.Cluster.Config.Raw, config) + if err != nil { + return nil, fmt.Errorf("unmarshal: %w", err) + } + + config.cacheKey = fmt.Sprintf("ske-login-%x", sha256.Sum256([]byte(execCredential.Spec.Cluster.Server))) + + return config, nil +} + +func getCachedKubeConfig(key string) *rest.Config { + cachedKubeconfig, err := cache.GetObject(key) + if err != nil { + return nil + } + + restConfig, err := clientcmd.RESTConfigFromKubeConfig(cachedKubeconfig) + if err != nil { + return nil + } + + return restConfig +} + +func GetAndOutputKubeconfig(ctx context.Context, p *print.Printer, apiClient *ske.APIClient, clusterConfig *clusterConfig, fallbackToCache bool, cachedKubeconfig *rest.Config) error { + req := buildRequest(ctx, apiClient, clusterConfig) + kubeconfigResponse, err := req.Execute() + if err != nil { + if fallbackToCache { + return output(p, clusterConfig.cacheKey, cachedKubeconfig) + } + return fmt.Errorf("request kubeconfig: %w", err) + } + + kubeconfig, err := clientcmd.RESTConfigFromKubeConfig([]byte(*kubeconfigResponse.Kubeconfig)) + if err != nil { + if fallbackToCache { + return output(p, clusterConfig.cacheKey, cachedKubeconfig) + } + return fmt.Errorf("parse kubeconfig: %w", err) + } + if err = cache.PutObject(clusterConfig.cacheKey, []byte(*kubeconfigResponse.Kubeconfig)); err != nil { + if fallbackToCache { + return output(p, clusterConfig.cacheKey, cachedKubeconfig) + } + return fmt.Errorf("cache kubeconfig: %w", err) + } + + return output(p, clusterConfig.cacheKey, kubeconfig) +} + +func buildRequest(ctx context.Context, apiClient *ske.APIClient, clusterConfig *clusterConfig) ske.ApiCreateKubeconfigRequest { + req := apiClient.CreateKubeconfig(ctx, clusterConfig.STACKITProjectID, clusterConfig.ClusterName) + expirationSeconds := strconv.Itoa(expirationSeconds) + + return req.CreateKubeconfigPayload(ske.CreateKubeconfigPayload{ExpirationSeconds: &expirationSeconds}) +} + +func output(p *print.Printer, cacheKey string, kubeconfig *rest.Config) error { + if kubeconfig == nil { + _ = cache.DeleteObject(cacheKey) + return errors.New("kubeconfig is nil") + } + + outputExecCredential, err := parseKubeConfigToExecCredential(kubeconfig) + if err != nil { + _ = cache.DeleteObject(cacheKey) + return fmt.Errorf("convert to ExecCredential: %w", err) + } + + output, err := json.Marshal(outputExecCredential) + if err != nil { + _ = cache.DeleteObject(cacheKey) + return fmt.Errorf("marshal ExecCredential: %w", err) + } + + p.Outputf(string(output), nil) + return nil +} + +func parseKubeConfigToExecCredential(kubeconfig *rest.Config) (*clientauthenticationv1.ExecCredential, error) { + certPem, _ := pem.Decode(kubeconfig.CertData) + if certPem == nil { + return nil, fmt.Errorf("decoded pem is nil") + } + + certificate, err := x509.ParseCertificate(certPem.Bytes) + if err != nil { + return nil, fmt.Errorf("parse certificate: %w", err) + } + + outputExecCredential := clientauthenticationv1.ExecCredential{ + TypeMeta: v1.TypeMeta{ + APIVersion: clientauthenticationv1.SchemeGroupVersion.String(), + Kind: "ExecCredential", + }, + Status: &clientauthenticationv1.ExecCredentialStatus{ + ExpirationTimestamp: &v1.Time{Time: certificate.NotAfter.Add(-time.Minute * 15)}, + ClientCertificateData: string(kubeconfig.CertData), + ClientKeyData: string(kubeconfig.KeyData), + }, + } + return &outputExecCredential, nil +} diff --git a/internal/cmd/ske/kubeconfig/login/login_test.go b/internal/cmd/ske/kubeconfig/login/login_test.go new file mode 100644 index 000000000..c6b94c9a9 --- /dev/null +++ b/internal/cmd/ske/kubeconfig/login/login_test.go @@ -0,0 +1,140 @@ +package login + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/ske" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1" + "k8s.io/client-go/rest" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() +var testClusterName = "cluster" + +func fixtureClusterConfig(mods ...func(clusterConfig *clusterConfig)) *clusterConfig { + clusterConfig := &clusterConfig{ + STACKITProjectID: testProjectId, + ClusterName: testClusterName, + cacheKey: "", + } + for _, mod := range mods { + mod(clusterConfig) + } + return clusterConfig +} + +func fixtureRequest(mods ...func(request *ske.ApiCreateKubeconfigRequest)) ske.ApiCreateKubeconfigRequest { + request := testClient.CreateKubeconfig(testCtx, testProjectId, testClusterName) + request = request.CreateKubeconfigPayload(ske.CreateKubeconfigPayload{}) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + clusterConfig *clusterConfig + expectedRequest ske.ApiCreateKubeconfigRequest + }{ + { + description: "expiration time", + clusterConfig: fixtureClusterConfig(), + expectedRequest: fixtureRequest().CreateKubeconfigPayload(ske.CreateKubeconfigPayload{ + ExpirationSeconds: utils.Ptr("1800")}), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, testClient, tt.clusterConfig) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestParseKubeConfigToExecCredential(t *testing.T) { + expectedTime, _ := time.Parse(time.RFC3339, "2024-01-01T00:45:00Z") + + tests := []struct { + description string + kubeconfig *rest.Config + expectedExecCredentialRequest *clientauthenticationv1.ExecCredential + }{ + { + description: "expiration time", + kubeconfig: &rest.Config{ + TLSClientConfig: rest.TLSClientConfig{ + CertData: []byte(`-----BEGIN CERTIFICATE----- +MIIBhTCCASugAwIBAgIIF8+zRM8UalAwCgYIKoZIzj0EAwIwGDEWMBQGA1UEAxMN +Y2EtY2xpZW50LXh5ejAeFw0yNDAxMDEwMDAwMDBaFw0yNDAxMDEwMTAwMDBaMC8x +FzAVBgNVBAoTDnN5c3RlbTptYXN0ZXJzMRQwEgYDVQQDEwtza2U6Y2x1c3RlcjBZ +MBMGByqGSM49AgEGCCqGSM49AwEHA0IABJaxZ8G4wEZ1xf44hMV1pQWsti5SL6PH +QF0bRniQEJHSOcZMwc0OrVIfuSV1qSMyvYIaFtBj1j9f2v8oPux7V02jSDBGMA4G +A1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDAjAfBgNVHSMEGDAWgBQt +Pn1pNgfb8xcdRVxVnHDIvb8abzAKBggqhkjOPQQDAgNIADBFAiEA8gG2l0schbMu +zbRjZmli7cnenEnfnNoFIGbgkbjGXRUCIC5zFtWXFK7kA+B2vDxD0DlLcQodNwi4 +2JKP8gT9ol16 +-----END CERTIFICATE-----`), + KeyData: []byte("keykeykey"), + }, + }, + expectedExecCredentialRequest: &clientauthenticationv1.ExecCredential{ + TypeMeta: v1.TypeMeta{ + APIVersion: clientauthenticationv1.SchemeGroupVersion.String(), + Kind: "ExecCredential", + }, + Status: &clientauthenticationv1.ExecCredentialStatus{ + ExpirationTimestamp: &v1.Time{Time: expectedTime}, + ClientCertificateData: `-----BEGIN CERTIFICATE----- +MIIBhTCCASugAwIBAgIIF8+zRM8UalAwCgYIKoZIzj0EAwIwGDEWMBQGA1UEAxMN +Y2EtY2xpZW50LXh5ejAeFw0yNDAxMDEwMDAwMDBaFw0yNDAxMDEwMTAwMDBaMC8x +FzAVBgNVBAoTDnN5c3RlbTptYXN0ZXJzMRQwEgYDVQQDEwtza2U6Y2x1c3RlcjBZ +MBMGByqGSM49AgEGCCqGSM49AwEHA0IABJaxZ8G4wEZ1xf44hMV1pQWsti5SL6PH +QF0bRniQEJHSOcZMwc0OrVIfuSV1qSMyvYIaFtBj1j9f2v8oPux7V02jSDBGMA4G +A1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDAjAfBgNVHSMEGDAWgBQt +Pn1pNgfb8xcdRVxVnHDIvb8abzAKBggqhkjOPQQDAgNIADBFAiEA8gG2l0schbMu +zbRjZmli7cnenEnfnNoFIGbgkbjGXRUCIC5zFtWXFK7kA+B2vDxD0DlLcQodNwi4 +2JKP8gT9ol16 +-----END CERTIFICATE-----`, + ClientKeyData: "keykeykey", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + execCredential, err := parseKubeConfigToExecCredential(tt.kubeconfig) + if err != nil { + t.Fatalf("func returned error: %s", err) + } + if execCredential == nil { + t.Fatal("execCredential is nil") + } + diff := cmp.Diff(execCredential, tt.expectedExecCredentialRequest) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/pkg/cache/cache.go b/internal/pkg/cache/cache.go new file mode 100644 index 000000000..abc81787d --- /dev/null +++ b/internal/pkg/cache/cache.go @@ -0,0 +1,86 @@ +package cache + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" +) + +var ( + cacheFolderPath string + + identifierRegex = regexp.MustCompile("^[a-zA-Z0-9-]+$") + ErrorInvalidCacheIdentifier = fmt.Errorf("invalid cache identifier") +) + +func Init() error { + cacheDir, err := os.UserCacheDir() + if err != nil { + return fmt.Errorf("get user cache dir: %w", err) + } + cacheFolderPath = filepath.Join(cacheDir, "stackit") + return nil +} + +func GetObject(identifier string) ([]byte, error) { + if err := validateCacheFolderPath(); err != nil { + return nil, err + } + if !identifierRegex.MatchString(identifier) { + return nil, ErrorInvalidCacheIdentifier + } + + return os.ReadFile(filepath.Join(cacheFolderPath, identifier)) +} + +func PutObject(identifier string, data []byte) error { + if err := validateCacheFolderPath(); err != nil { + return err + } + if !identifierRegex.MatchString(identifier) { + return ErrorInvalidCacheIdentifier + } + + err := createFolderIfNotExists(cacheFolderPath) + if err != nil { + return err + } + + return os.WriteFile(filepath.Join(cacheFolderPath, identifier), data, 0o600) +} + +func DeleteObject(identifier string) error { + if err := validateCacheFolderPath(); err != nil { + return err + } + if !identifierRegex.MatchString(identifier) { + return ErrorInvalidCacheIdentifier + } + + if err := os.Remove(filepath.Join(cacheFolderPath, identifier)); !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +func createFolderIfNotExists(folderPath string) error { + _, err := os.Stat(folderPath) + if os.IsNotExist(err) { + err := os.MkdirAll(folderPath, os.ModePerm) + if err != nil { + return err + } + } else if err != nil { + return err + } + return nil +} + +func validateCacheFolderPath() error { + if cacheFolderPath == "" { + return errors.New("cacheFolderPath not set. Forgot to call Init()?") + } + return nil +} diff --git a/internal/pkg/cache/cache_test.go b/internal/pkg/cache/cache_test.go new file mode 100644 index 000000000..9cdf34ce4 --- /dev/null +++ b/internal/pkg/cache/cache_test.go @@ -0,0 +1,207 @@ +package cache + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" +) + +func TestGetObject(t *testing.T) { + if err := Init(); err != nil { + t.Fatalf("cache init failed: %s", err) + } + + tests := []struct { + description string + identifier string + expectFile bool + expectedErr error + }{ + { + description: "identifier exists", + identifier: "test-cache-get-exists", + expectFile: true, + expectedErr: nil, + }, + { + description: "identifier does not exist", + identifier: "test-cache-get-not-exists", + expectFile: false, + expectedErr: os.ErrNotExist, + }, + { + description: "identifier is invalid", + identifier: "in../../valid", + expectFile: false, + expectedErr: ErrorInvalidCacheIdentifier, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + id := tt.identifier + "-" + uuid.NewString() + + // setup + if tt.expectFile { + err := createFolderIfNotExists(cacheFolderPath) + if err != nil { + t.Fatalf("create cache folder: %s", err.Error()) + } + path := filepath.Join(cacheFolderPath, id) + if err := os.WriteFile(path, []byte("dummy"), 0o600); err != nil { + t.Fatalf("setup: WriteFile (%s) failed", path) + } + } + // test + file, err := GetObject(id) + + if !errors.Is(err, tt.expectedErr) { + t.Fatalf("returned error (%q) does not match %q", err.Error(), tt.expectedErr.Error()) + } + + if tt.expectFile { + if len(file) < 1 { + t.Fatalf("expected a file but byte array is empty (len %d)", len(file)) + } + } else { + if len(file) > 0 { + t.Fatalf("didn't expect a file, but byte array is not empty (len %d)", len(file)) + } + } + }) + } +} +func TestPutObject(t *testing.T) { + if err := Init(); err != nil { + t.Fatalf("cache init failed: %s", err) + } + + tests := []struct { + description string + identifier string + existingFile bool + expectFile bool + expectedErr error + customPath string + }{ + { + description: "identifier already exists", + identifier: "test-cache-put-exists", + existingFile: true, + expectFile: true, + expectedErr: nil, + }, + { + description: "identifier does not exist", + identifier: "test-cache-put-not-exists", + expectFile: true, + expectedErr: nil, + }, + { + description: "identifier is invalid", + identifier: "in../../valid", + expectFile: false, + expectedErr: ErrorInvalidCacheIdentifier, + }, + { + description: "directory does not yet exist", + identifier: "test-cache-put-folder-not-exists", + expectFile: true, + expectedErr: nil, + customPath: "/tmp/stackit-cli-test", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + id := tt.identifier + "-" + uuid.NewString() + if tt.customPath != "" { + cacheFolderPath = tt.customPath + } else { + cacheDir, _ := os.UserCacheDir() + cacheFolderPath = filepath.Join(cacheDir, "stackit") + } + path := filepath.Join(cacheFolderPath, id) + + // setup + if tt.existingFile { + if err := os.WriteFile(path, []byte("dummy"), 0o600); err != nil { + t.Fatalf("setup: WriteFile (%s) failed", path) + } + } + // test + err := PutObject(id, []byte("dummy")) + + if !errors.Is(err, tt.expectedErr) { + t.Fatalf("returned error (%q) does not match %q", err.Error(), tt.expectedErr.Error()) + } + + if tt.expectFile { + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected file (%q) to exist", path) + } + } + }) + } +} + +func TestDeleteObject(t *testing.T) { + if err := Init(); err != nil { + t.Fatalf("cache init failed: %s", err) + } + + tests := []struct { + description string + identifier string + existingFile bool + expectedErr error + }{ + { + description: "identifier exists", + identifier: "test-cache-delete-exists", + existingFile: true, + expectedErr: nil, + }, + { + description: "identifier does not exist", + identifier: "test-cache-delete-not-exists", + existingFile: false, + expectedErr: nil, + }, + { + description: "identifier is invalid", + identifier: "in../../valid", + existingFile: false, + expectedErr: ErrorInvalidCacheIdentifier, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + id := tt.identifier + "-" + uuid.NewString() + path := filepath.Join(cacheFolderPath, id) + + // setup + if tt.existingFile { + if err := os.WriteFile(path, []byte("dummy"), 0o600); err != nil { + t.Fatalf("setup: WriteFile (%s) failed", path) + } + } + // test + err := DeleteObject(id) + + if !errors.Is(err, tt.expectedErr) { + t.Fatalf("returned error (%q) does not match %q", err.Error(), tt.expectedErr.Error()) + } + + if tt.existingFile { + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected file (%q) to not exist", path) + } + } + }) + } +}