diff --git a/README.md b/README.md index 77478b9..907c28d 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,12 @@ Use `datumctl` to manage your Datum Cloud resources, authenticate securely, and ## Features -* **Secure Authentication:** Uses modern OAuth 2.0 and OIDC PKCE flow for secure, browser-based login. No static API keys required. -* **Multi-User Support:** Manage credentials for multiple Datum Cloud user accounts. -* **Resource Management:** Interact with Datum Cloud resources (e.g., list organizations). -* **Kubernetes Integration:** Seamlessly configure `kubectl` to use your Datum Cloud credentials for accessing Kubernetes clusters. +* **Secure Authentication:** Modern OAuth 2.0 / OIDC PKCE with device-code fallback for headless environments. No static API keys. +* **Context Discovery:** After login, `datumctl` fetches the organizations and projects you can access and lets you pick a default context — no more passing `--organization` or `--project` on every command. +* **Multi-User Support:** Manage credentials for multiple Datum Cloud accounts and switch between them with `datumctl auth switch`. +* **Resource Management:** Interact with Datum Cloud resources with a kubectl-style interface (`get`, `apply`, `describe`, `delete`, ...). +* **Kubernetes Integration:** Configure `kubectl` to use your Datum Cloud credentials for accessing control planes. +* **AI Agents / MCP:** The standalone [`datum-mcp`](https://github.com/datum-cloud/datum-mcp) project provides a Model Context Protocol server for integrating Datum Cloud with Claude and other AI tools. * **Cross-Platform:** Pre-built binaries available for Linux, macOS, and Windows. ## Getting Started @@ -22,61 +24,48 @@ See the [Installation Guide](https://www.datum.net/docs/quickstart/datumctl/) fo ### Basic Usage -1. **Log in to Datum Cloud:** +1. **Log in and pick a context:** ```bash - datumctl auth login + datumctl login ``` - (This will open your web browser to complete authentication.) + Opens your browser for authentication, then fetches your organizations and projects and prompts you to pick a default context. If you only have a single project, the picker is skipped. -2. **List your organizations:** +2. **Work with resources:** ```bash - datumctl get organizations + datumctl get dnszones # uses the active context automatically + datumctl get organizations # list your org memberships + datumctl api-resources # discover available resource types ``` -3. **Configure `kubectl` access (optional):** - Use the organization ID (or a specific project ID) from the previous step - to configure `kubectl`. +3. **Switch contexts or accounts:** ```bash - # Example using an organization ID + datumctl ctx # list contexts (tree view by org) + datumctl ctx use my-org/my-project + datumctl auth list # list accounts + datumctl auth switch alice@example.com + ``` + +4. **Configure `kubectl` access (optional):** + ```bash + # Point kubectl at your organization's control plane datumctl auth update-kubeconfig --organization - # Example using a project ID + # Or at a specific project's control plane datumctl auth update-kubeconfig --project ``` - Now you can use `kubectl` to interact with your Datum Cloud control plane. + kubectl then uses `datumctl auth get-token` automatically to refresh credentials. -**A) If you already have a project:** -```bash -# Ensure your kube context points at an organization control plane -datumctl auth update-kubeconfig --organization +### CI and scripting -# List projects; copy the NAME column (that is the Project ID) -kubectl get projects -# Or JSON-friendly: -kubectl get projects -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' -``` +For non-interactive use, environment variables override the active context per-invocation: -**B) If you need to create a project:** ```bash -# Make sure your kube context targets an organization control plane -datumctl auth update-kubeconfig --organization - -cat > intro-project.yaml <<'YAML' -apiVersion: resourcemanager.miloapis.com/v1alpha1 -kind: Project -metadata: - generateName: intro-project- -spec: {} -YAML - -kubectl create -f intro-project.yaml - -# Wait until Ready -PRJ_ID="$(kubectl get projects -o jsonpath='{.items[-1:].metadata.name}')" -kubectl wait --for=condition=Ready --timeout=15m project/$PRJ_ID -echo "Project ready: $PRJ_ID" +DATUM_PROJECT=my-project datumctl get dnszones +DATUM_ORGANIZATION=my-org datumctl get projects ``` +`--project` and `--organization` flags work too. For machine-to-machine auth, see `datumctl auth login --credentials` for the machine-account flow. + ## Documentation For comprehensive user and developer guides, including detailed command references and authentication flow explanations, please see the [**Documentation**](./docs/README.md). diff --git a/go.mod b/go.mod index a4bac35..56731b4 100644 --- a/go.mod +++ b/go.mod @@ -27,15 +27,30 @@ require ( require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chai2010/gettext-go v1.0.3 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/huh v1.0.0 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/fatih/camelcase v1.0.0 // indirect @@ -67,12 +82,20 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/lithammer/dedent v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/moby/spdystream v0.5.0 // indirect github.com/moby/term v0.5.2 // 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/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/opencontainers/go-digest v1.0.0 // indirect @@ -82,9 +105,11 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect diff --git a/go.sum b/go.sum index d3d0614..815a4df 100644 --- a/go.sum +++ b/go.sum @@ -6,14 +6,38 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/gettext-go v1.0.3 h1:9liNh8t+u26xl5ddmWLmsOsdNLwkdRTg5AG+JnTiM80= github.com/chai2010/gettext-go v1.0.3/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= @@ -22,6 +46,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -30,8 +55,12 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= @@ -119,10 +148,18 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= @@ -135,6 +172,12 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 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/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= @@ -160,6 +203,8 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rodaine/table v1.3.1 h1:jBVgg1bEu5EzEdYSrwUUlQpayDtkvtTmgFS0FPAxOq8= github.com/rodaine/table v1.3.1/go.mod h1:VYCJRCHa2DpD25uFALcB6hi5ECF3eEJQVhCXRjHgXc4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -185,6 +230,8 @@ 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/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.miloapis.com/activity v0.3.1 h1:Yq8pdfphiAqr3DqZNQ0a50SadHrbdZyqng/HEwHe4WI= @@ -214,7 +261,9 @@ golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7 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.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= diff --git a/internal/authutil/context.go b/internal/authutil/context.go new file mode 100644 index 0000000..b1d1b57 --- /dev/null +++ b/internal/authutil/context.go @@ -0,0 +1,176 @@ +package authutil + +import ( + "encoding/json" + "errors" + "fmt" + + "go.datum.net/datumctl/internal/datumconfig" + "go.datum.net/datumctl/internal/keyring" +) + +// GetUserKeyForCurrentSession resolves the user key from the active v1beta1 +// session. If no session exists in the config file, it performs a one-time +// bootstrap from existing keyring credentials — for users who logged in +// before the v1beta1 config layer existed. +func GetUserKeyForCurrentSession() (string, *datumconfig.Session, error) { + cfg, err := datumconfig.LoadAuto() + if err != nil { + return "", nil, err + } + + if session := cfg.ActiveSessionEntry(); session != nil && session.UserKey != "" { + return session.UserKey, session, nil + } + + session, err := bootstrapSessionFromKeyring(cfg) + if err != nil { + return "", nil, err + } + return session.UserKey, session, nil +} + +// GetUserKey returns just the user key for the active session. Convenience +// wrapper for callers that don't need the session itself. +func GetUserKey() (string, error) { + key, _, err := GetUserKeyForCurrentSession() + return key, err +} + +// GetUserKeyForSession looks up a session by name and returns its user key. +// Used by the kubectl exec plugin path: `datumctl auth update-kubeconfig` +// writes the session name into the exec args, and `datumctl auth get-token` +// resolves it back here. +func GetUserKeyForSession(sessionName string) (string, error) { + if sessionName == "" { + return "", ErrNoActiveUser + } + cfg, err := datumconfig.LoadAuto() + if err != nil { + return "", err + } + session := cfg.SessionByName(sessionName) + if session == nil { + return "", fmt.Errorf("no session named %q — run 'datumctl login' or 'datumctl auth update-kubeconfig'", sessionName) + } + if session.UserKey == "" { + return "", fmt.Errorf("session %q has no user key", sessionName) + } + return session.UserKey, nil +} + +// bootstrapSessionFromKeyring detects a pre-v1beta1 user — credentials in the +// keyring with no config file — and creates a v1beta1 Session for them so +// subsequent commands "just work." Returns the new session, or an error if no +// usable keyring credentials are present. +// +// Works for both interactive and machine-account credentials, since both +// populate the same StoredCredentials fields that Session consumes. +func bootstrapSessionFromKeyring(cfg *datumconfig.ConfigV1Beta1) (*datumconfig.Session, error) { + if cfg == nil { + return nil, ErrNoActiveUser + } + + candidateKey, err := pickLegacyUserKey() + if err != nil { + return nil, err + } + if candidateKey == "" { + return nil, ErrNoActiveUser + } + + creds, err := GetStoredCredentials(candidateKey) + if err != nil { + return nil, err + } + + apiHostname := creds.APIHostname + if apiHostname == "" { + apiHostname, err = DeriveAPIHostname(creds.Hostname) + if err != nil { + return nil, err + } + } + + userKey, err := normalizeUserKey(candidateKey, creds) + if err != nil { + return nil, err + } + + session := datumconfig.Session{ + Name: datumconfig.SessionName(creds.UserEmail, apiHostname), + UserKey: userKey, + UserEmail: creds.UserEmail, + UserName: creds.UserName, + Endpoint: datumconfig.Endpoint{ + Server: datumconfig.CleanBaseServer(datumconfig.EnsureScheme(apiHostname)), + AuthHostname: creds.Hostname, + }, + } + + cfg.UpsertSession(session) + if cfg.ActiveSession == "" { + cfg.ActiveSession = session.Name + } + if err := datumconfig.SaveV1Beta1(cfg); err != nil { + return nil, err + } + + return &session, nil +} + +// pickLegacyUserKey returns the keyring user key to bootstrap from. Prefers +// the legacy ActiveUserKey; falls back to KnownUsersKey when exactly one user +// is known. +func pickLegacyUserKey() (string, error) { + legacyKey, err := keyring.Get(ServiceName, ActiveUserKey) + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return "", fmt.Errorf("failed to read legacy active user: %w", err) + } + if legacyKey != "" { + return legacyKey, nil + } + + knownUsersJSON, err := keyring.Get(ServiceName, KnownUsersKey) + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return "", fmt.Errorf("failed to read known users: %w", err) + } + if knownUsersJSON == "" { + return "", nil + } + + var knownUsers []string + if err := json.Unmarshal([]byte(knownUsersJSON), &knownUsers); err != nil { + return "", fmt.Errorf("failed to unmarshal known users: %w", err) + } + if len(knownUsers) == 1 { + return knownUsers[0], nil + } + return "", nil +} + +// normalizeUserKey ensures the keyring stores the user under the canonical +// "@" key. If the existing key is in an older format, it +// re-stores the credentials under the new key and returns the new key. +func normalizeUserKey(userKey string, creds *StoredCredentials) (string, error) { + if creds.Subject == "" || creds.Hostname == "" { + return userKey, nil + } + + canonical := fmt.Sprintf("%s@%s", creds.Subject, creds.Hostname) + if canonical == userKey { + return userKey, nil + } + + credsJSON, err := json.Marshal(creds) + if err != nil { + return "", err + } + if err := keyring.Set(ServiceName, canonical, string(credsJSON)); err != nil { + return "", err + } + if err := AddKnownUserKey(canonical); err != nil { + return "", err + } + return canonical, nil +} diff --git a/internal/authutil/credentials.go b/internal/authutil/credentials.go index 620886f..6732ff7 100644 --- a/internal/authutil/credentials.go +++ b/internal/authutil/credentials.go @@ -172,7 +172,20 @@ func GetTokenSource(ctx context.Context) (oauth2.TokenSource, error) { if err != nil { return nil, err } + return tokenSourceFor(ctx, userKey, creds) +} + +// GetTokenSourceForUser creates an oauth2.TokenSource for a specific user key. +// Used by multi-user flows (sessions, kubectl exec plugin, MCP). +func GetTokenSourceForUser(ctx context.Context, userKey string) (oauth2.TokenSource, error) { + creds, err := GetStoredCredentials(userKey) + if err != nil { + return nil, err + } + return tokenSourceFor(ctx, userKey, creds) +} +func tokenSourceFor(ctx context.Context, userKey string, creds *StoredCredentials) (oauth2.TokenSource, error) { if creds.CredentialType == "machine_account" { if creds.MachineAccount == nil { return nil, fmt.Errorf("machine account credentials are missing from stored session") @@ -213,11 +226,22 @@ func GetUserIDFromToken(ctx context.Context) (string, error) { if err != nil { return "", err } + return userIDFromCreds(creds) +} + +// GetUserIDFromTokenForUser extracts the user ID (sub claim) for a specific user key. +func GetUserIDFromTokenForUser(userKey string) (string, error) { + creds, err := GetStoredCredentials(userKey) + if err != nil { + return "", err + } + return userIDFromCreds(creds) +} +func userIDFromCreds(creds *StoredCredentials) (string, error) { if creds.Subject == "" { return "", errors.New("subject (user ID) not found in stored credentials") } - return creds.Subject, nil } @@ -245,13 +269,23 @@ func GetAPIHostname() (string, error) { if err != nil { return "", err } + return apiHostnameFromCreds(creds) +} + +// GetAPIHostnameForUser returns the API hostname from stored credentials for +// a specific user key. +func GetAPIHostnameForUser(userKey string) (string, error) { + creds, err := GetStoredCredentials(userKey) + if err != nil { + return "", err + } + return apiHostnameFromCreds(creds) +} - // If API hostname is explicitly stored, use it +func apiHostnameFromCreds(creds *StoredCredentials) (string, error) { if creds.APIHostname != "" { return creds.APIHostname, nil } - - // Fall back to deriving from auth hostname return DeriveAPIHostname(creds.Hostname) } diff --git a/internal/authutil/known_users.go b/internal/authutil/known_users.go new file mode 100644 index 0000000..2a4b7a9 --- /dev/null +++ b/internal/authutil/known_users.go @@ -0,0 +1,46 @@ +package authutil + +import ( + "encoding/json" + "errors" + "fmt" + + "go.datum.net/datumctl/internal/keyring" +) + +// AddKnownUserKey adds a userKey (subject@hostname) to the known_users list in the keyring. +func AddKnownUserKey(newUserKey string) error { + knownUsers := []string{} + + knownUsersJSON, err := keyring.Get(ServiceName, KnownUsersKey) + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return fmt.Errorf("failed to get known users list from keyring: %w", err) + } + + if err == nil && knownUsersJSON != "" { + if err := json.Unmarshal([]byte(knownUsersJSON), &knownUsers); err != nil { + return fmt.Errorf("failed to unmarshal known users list: %w", err) + } + } + + found := false + for _, key := range knownUsers { + if key == newUserKey { + found = true + break + } + } + + if !found { + knownUsers = append(knownUsers, newUserKey) + updatedJSON, err := json.Marshal(knownUsers) + if err != nil { + return fmt.Errorf("failed to marshal updated known users list: %w", err) + } + if err := keyring.Set(ServiceName, KnownUsersKey, string(updatedJSON)); err != nil { + return fmt.Errorf("failed to store updated known users list: %w", err) + } + } + + return nil +} diff --git a/internal/cmd/auth/login.go b/internal/authutil/login.go similarity index 54% rename from internal/cmd/auth/login.go rename to internal/authutil/login.go index 9a52358..24d8474 100644 --- a/internal/cmd/auth/login.go +++ b/internal/authutil/login.go @@ -1,4 +1,4 @@ -package auth +package authutil import ( "context" @@ -6,180 +6,119 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" - "errors" "fmt" "io" "net" "net/http" "net/url" - "os" "strings" "time" - kubectlcmd "k8s.io/kubectl/pkg/cmd" - - "github.com/coreos/go-oidc/v3/oidc" // OIDC discovery + "github.com/coreos/go-oidc/v3/oidc" "github.com/pkg/browser" - "github.com/spf13/cobra" - "golang.org/x/oauth2" - - "go.datum.net/datumctl/internal/authutil" // Import new authutil package + "go.datum.net/datumctl/internal/datumconfig" "go.datum.net/datumctl/internal/keyring" + "golang.org/x/oauth2" ) const ( - stagingClientID = "325848904128073754" // Client ID for staging - prodClientID = "328728232771788043" // Client ID for prod + StagingClientID = "325848904128073754" + ProdClientID = "328728232771788043" redirectPath = "/datumctl/auth/callback" - // Listen on a random port - listenAddr = "localhost:0" -) - -var ( - hostname string // Variable to store hostname flag - apiHostname string // Variable to store api-hostname flag - clientIDFlag string // Variable to store client-id flag - noBrowser bool // Variable to store no-browser flag - credentialsFile string // Variable to store credentials file path flag - debugCredentials bool // Variable to store debug flag for credentials flow + listenAddr = "localhost:0" ) -var LoginCmd = &cobra.Command{ - Use: "login", - Short: "Log in to Datum Cloud", - Long: `Authenticate with Datum Cloud using a secure browser-based login flow. - -Running this command will: - 1. Open your default web browser to the Datum Cloud authentication page. - If the browser cannot open automatically, a URL is printed for manual use. - 2. Complete authentication in the browser (username/password or SSO). - 3. Return to datumctl, which stores your credentials (including a refresh - token) securely in the system keyring. - -After login, credentials are associated with your email address and -automatically refreshed when they expire. Use 'datumctl auth list' to see -all stored sessions. - -By default, logs into auth.datum.net (the production Datum Cloud environment). -Use --hostname to target a different environment (e.g., staging). -Use --api-hostname to explicitly specify the API server hostname when it -cannot be derived from the auth hostname (e.g., in self-hosted environments).`, - Example: ` # Log in to Datum Cloud (opens browser) - datumctl auth login - - # Log in to a staging environment - datumctl auth login --hostname auth.staging.env.datum.net - - # Log in to a self-hosted environment with an explicit client ID - datumctl auth login --hostname auth.example.com --client-id 123456789 - - # Log in to a self-hosted environment with explicit API hostname - datumctl auth login --hostname auth.example.com --api-hostname api.example.com --client-id 123456789 - - # Log in with a machine account credentials file (hostname is required - # to tell datumctl which environment to authenticate against) - datumctl auth login --credentials ./my-key.json --hostname auth.staging.env.datum.net`, - RunE: func(cmd *cobra.Command, args []string) error { - if credentialsFile != "" { - return runMachineAccountLogin(cmd.Context(), credentialsFile, hostname, apiHostname, debugCredentials) - } - - var actualClientID string - if clientIDFlag != "" { - actualClientID = clientIDFlag - } else if strings.HasSuffix(hostname, ".staging.env.datum.net") { - actualClientID = stagingClientID - } else if strings.HasSuffix(hostname, ".datum.net") { - actualClientID = prodClientID - } else { - // Return an error if no client ID could be determined - return fmt.Errorf("client ID not configured for hostname '%s'. Please specify one with the --client-id flag", hostname) - } - return runLoginFlow(cmd.Context(), hostname, apiHostname, actualClientID, noBrowser, (kubectlcmd.GetLogVerbosity(os.Args) != "0")) - }, -} - -func init() { - // Add the hostname flag - LoginCmd.Flags().StringVar(&hostname, "hostname", "auth.datum.net", "Hostname of the Datum Cloud authentication server") - // Add the api-hostname flag - LoginCmd.Flags().StringVar(&apiHostname, "api-hostname", "", "Hostname of the Datum Cloud API server (if not specified, will be derived from auth hostname)") - // Add the client-id flag - LoginCmd.Flags().StringVar(&clientIDFlag, "client-id", "", "Override the OAuth2 Client ID") - // Add the no-browser flag - LoginCmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Do not open a browser; use the device authorization flow") - // Add the credentials flag for machine account (non-interactive) login - LoginCmd.Flags().StringVar(&credentialsFile, "credentials", "", "Path to a machine account credentials JSON file downloaded from the Datum Cloud portal") - LoginCmd.Flags().BoolVar(&debugCredentials, "debug", false, "Print JWT claims and token request details (credentials flow only)") +// LoginResult holds the output of a successful login flow. +type LoginResult struct { + UserKey string + UserEmail string + UserName string + Subject string + APIHostname string } -// Generates a random PKCE code verifier -func generateCodeVerifier() (string, error) { - const length = 64 - randomBytes := make([]byte, length) - _, err := rand.Read(randomBytes) - if err != nil { - return "", fmt.Errorf("failed to generate random bytes: %w", err) +// BuildSession constructs a v1beta1 Session from a login result. +// The caller is responsible for upserting it into the config and saving. +func BuildSession(result *LoginResult, authHostname string) datumconfig.Session { + apiHostname := result.APIHostname + return datumconfig.Session{ + Name: datumconfig.SessionName(result.UserEmail, apiHostname), + UserKey: result.UserKey, + UserEmail: result.UserEmail, + UserName: result.UserName, + Endpoint: datumconfig.Endpoint{ + Server: datumconfig.CleanBaseServer(datumconfig.EnsureScheme(apiHostname)), + AuthHostname: authHostname, + }, } - return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(randomBytes), nil } -// Generates the PKCE code challenge from the verifier -func generateCodeChallenge(verifier string) string { - hash := sha256.Sum256([]byte(verifier)) - return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:]) -} - -// generateRandomState generates a cryptographically random string for CSRF protection. -func generateRandomState(length int) (string, error) { - b := make([]byte, length) - _, err := rand.Read(b) - if err != nil { - return "", err +// ResolveClientID determines the OAuth2 client ID for a given auth hostname. +func ResolveClientID(clientIDFlag, authHostname string) (string, error) { + if clientIDFlag != "" { + return clientIDFlag, nil } - return base64.RawURLEncoding.EncodeToString(b), nil + if strings.HasSuffix(authHostname, ".staging.env.datum.net") { + return StagingClientID, nil + } + if strings.HasSuffix(authHostname, ".datum.net") { + return ProdClientID, nil + } + return "", fmt.Errorf("client ID not configured for hostname '%s'. Please specify one with the --client-id flag", authHostname) } -// runLoginFlow now accepts context, hostname, apiHostname, clientID, and verbose flag -func runLoginFlow(ctx context.Context, authHostname string, apiHostname string, clientID string, noBrowser bool, verbose bool) error { +// RunInteractiveLogin executes an interactive OAuth2 login flow (PKCE or device) and stores +// credentials in the keyring. It returns the login result on success. +// +// When noBrowser is true the device authorization flow is used, which does not +// require a browser on the local machine. When false the PKCE flow is used and +// the user's default browser is opened automatically. +func RunInteractiveLogin(ctx context.Context, authHostname, apiHostname, clientID string, noBrowser, verbose bool) (*LoginResult, error) { fmt.Printf("Starting login process for %s ...\n", authHostname) - // Determine the final API hostname to use var finalAPIHostname string if apiHostname != "" { - // Use the explicitly provided API hostname finalAPIHostname = apiHostname fmt.Printf("Using specified API hostname: %s\n", finalAPIHostname) } else { - // Derive API hostname from auth hostname - derivedAPI, err := authutil.DeriveAPIHostname(authHostname) + derived, err := DeriveAPIHostname(authHostname) if err != nil { - return fmt.Errorf("failed to derive API hostname from auth hostname '%s': %w", authHostname, err) + return nil, fmt.Errorf("failed to derive API hostname from '%s': %w", authHostname, err) } - finalAPIHostname = derivedAPI + finalAPIHostname = derived fmt.Printf("Derived API hostname: %s\n", finalAPIHostname) } providerURL := fmt.Sprintf("https://%s", authHostname) provider, err := oidc.NewProvider(ctx, providerURL) if err != nil { - return fmt.Errorf("failed to discover OIDC provider at %s: %w", providerURL, err) + return nil, fmt.Errorf("failed to discover OIDC provider at %s: %w", providerURL, err) } - // Define scopes scopes := []string{oidc.ScopeOpenID, "profile", "email", oidc.ScopeOfflineAccess} + var token *oauth2.Token if noBrowser { - token, err := runDeviceFlow(ctx, providerURL, clientID, scopes) + token, err = runDeviceFlow(ctx, providerURL, clientID, scopes) if err != nil { - return err + return nil, err + } + } else { + token, err = runPKCEFlow(ctx, provider, clientID, scopes) + if err != nil { + return nil, err } - return completeLogin(ctx, provider, clientID, authHostname, finalAPIHostname, scopes, token, verbose) } + return completeLogin(ctx, provider, clientID, authHostname, finalAPIHostname, scopes, token, verbose) +} + +// runPKCEFlow executes the OAuth2 PKCE authorization code flow and returns the +// resulting token. It starts a local HTTP server to receive the callback. +func runPKCEFlow(ctx context.Context, provider *oidc.Provider, clientID string, scopes []string) (*oauth2.Token, error) { listener, err := net.Listen("tcp", listenAddr) if err != nil { - return fmt.Errorf("failed to listen on %s: %w", listenAddr, err) + return nil, fmt.Errorf("failed to listen on %s: %w", listenAddr, err) } defer listener.Close() @@ -192,39 +131,33 @@ func runLoginFlow(ctx context.Context, authHostname string, apiHostname string, RedirectURL: fmt.Sprintf("http://%s%s", actualListenAddr, redirectPath), } - // Generate PKCE parameters codeVerifier, err := generateCodeVerifier() if err != nil { - return fmt.Errorf("failed to generate code verifier: %w", err) + return nil, fmt.Errorf("failed to generate code verifier: %w", err) } codeChallenge := generateCodeChallenge(codeVerifier) - // Generate random state state, err := generateRandomState(32) if err != nil { - return fmt.Errorf("failed to generate state: %w", err) + return nil, fmt.Errorf("failed to generate state: %w", err) } - // Construct the authorization URL authURL := conf.AuthCodeURL(state, oauth2.SetAuthURLParam("code_challenge", codeChallenge), oauth2.SetAuthURLParam("code_challenge_method", "S256"), oauth2.SetAuthURLParam("prompt", "select_account"), ) - // Channel to receive the authorization code codeChan := make(chan string) errChan := make(chan error) - serverClosed := make(chan struct{}) // To signal server shutdown completion + serverClosed := make(chan struct{}) - // Start local server to handle the callback server := &http.Server{} - mux := http.NewServeMux() // Use a mux to avoid conflicts if other handlers exist + mux := http.NewServeMux() mux.HandleFunc(redirectPath, func(w http.ResponseWriter, r *http.Request) { code := r.URL.Query().Get("code") receivedState := r.URL.Query().Get("state") - // Validate received state against the original state if receivedState != state { http.Error(w, "Invalid state parameter", http.StatusBadRequest) errChan <- fmt.Errorf("invalid state parameter received (expected %q, got %q)", state, receivedState) @@ -246,34 +179,29 @@ func runLoginFlow(ctx context.Context, authHostname string, apiHostname string, return } - // Redirect to documentation site upon success http.Redirect(w, r, "https://www.datum.net/docs/datumctl/cli-reference/#see-also", http.StatusFound) - - codeChan <- code // Send code - // Server shutdown will be initiated by the main goroutine now + codeChan <- code }) server.Handler = mux go func() { if err := server.Serve(listener); err != http.ErrServerClosed { - // Don't send error if context is cancelled (which might happen on success) select { case <-ctx.Done(): - // Expected shutdown due to successful auth or cancellation default: errChan <- fmt.Errorf("failed to start callback server: %w", err) } } }() - // Attempt to open browser fmt.Println("\nAttempting to open your default browser for authentication...") fmt.Printf("\nOpen this URL in your browser: %s\n", authURL) - err = browser.OpenURL(authURL) - if err != nil { + if err := browser.OpenURL(authURL); err != nil { fmt.Println("\nCould not open browser automatically.") fmt.Println("Please visit this URL manually to authenticate:") fmt.Printf("\n%s\n\n", authURL) + fmt.Println("If you are in a headless environment (CI, SSH without forwarding, or a container) consider") + fmt.Println("'datumctl auth login --no-browser' instead — it uses a device-code flow that doesn't need a browser on this machine.") } else { fmt.Println("Please complete the authentication in your browser.") } @@ -284,55 +212,45 @@ func runLoginFlow(ctx context.Context, authHostname string, apiHostname string, select { case code := <-codeChan: authCode = code - // Initiate server shutdown *after* receiving the code go func() { if err := server.Shutdown(context.Background()); err != nil { - // Log error if needed + // Best-effort shutdown; ignore error. } close(serverClosed) }() case err := <-errChan: - // Don't wait for serverClosed here if auth already failed - return fmt.Errorf("authentication failed: %w", err) + return nil, fmt.Errorf("authentication failed: %w", err) case <-ctx.Done(): - // If context is cancelled, still try to shut down gracefully - go server.Shutdown(context.Background()) // Best effort - // Don't necessarily wait for serverClosed here either - return ctx.Err() + go server.Shutdown(context.Background()) + return nil, ctx.Err() } - // Remove the blocking wait before exchange - // <-serverClosed - - // Exchange code for token (now happens sooner) token, err := conf.Exchange(ctx, authCode, oauth2.SetAuthURLParam("code_verifier", codeVerifier), ) if err != nil { - // If exchange fails, wait for server shutdown before returning for cleaner exit <-serverClosed - return fmt.Errorf("failed to exchange code for token: %w", err) + return nil, fmt.Errorf("failed to exchange code for token: %w", err) } - - // Wait for server shutdown *after* successful exchange (or failed exchange) <-serverClosed - return completeLogin(ctx, provider, clientID, authHostname, finalAPIHostname, scopes, token, verbose) + return token, nil } -func completeLogin(ctx context.Context, provider *oidc.Provider, clientID string, authHostname string, finalAPIHostname string, scopes []string, token *oauth2.Token, verbose bool) error { - // Verify ID token and extract claims +// completeLogin verifies the ID token, stores credentials in the keyring, sets +// the active user, registers the user in the known-users list, and returns a +// LoginResult. +func completeLogin(ctx context.Context, provider *oidc.Provider, clientID, authHostname, finalAPIHostname string, scopes []string, token *oauth2.Token, verbose bool) (*LoginResult, error) { idTokenString, ok := token.Extra("id_token").(string) if !ok { - return fmt.Errorf("id_token not found in token response") + return nil, fmt.Errorf("id_token not found in token response") } - idToken, err := provider.Verifier(&oidc.Config{ClientID: clientID}).Verify(ctx, idTokenString) // Use passed-in clientID + idToken, err := provider.Verifier(&oidc.Config{ClientID: clientID}).Verify(ctx, idTokenString) if err != nil { - return fmt.Errorf("failed to verify ID token: %w", err) + return nil, fmt.Errorf("failed to verify ID token: %w", err) } - // Extract claims, including the subject ('sub') var claims struct { Subject string `json:"sub"` Email string `json:"email"` @@ -340,23 +258,23 @@ func completeLogin(ctx context.Context, provider *oidc.Provider, clientID string Name string `json:"name"` } if err := idToken.Claims(&claims); err != nil { - return fmt.Errorf("failed to extract claims from ID token: %w", err) + return nil, fmt.Errorf("failed to extract claims from ID token: %w", err) } - // Ensure essential claims are present if claims.Subject == "" { - return fmt.Errorf("could not extract subject (sub) claim from ID token") + return nil, fmt.Errorf("could not extract subject (sub) claim from ID token") } if claims.Email == "" { - return fmt.Errorf("could not extract email claim from ID token, which is required for user identification") + return nil, fmt.Errorf("could not extract email claim from ID token, which is required for user identification") } fmt.Printf("\nAuthenticated as: %s (%s)\n", claims.Name, claims.Email) - // Use email directly as the key, as it already contains the hostname from the claim + // Use email as the keyring key for interactive logins, matching the + // behaviour of the original cmd/auth/login.go implementation. userKey := claims.Email - creds := authutil.StoredCredentials{ + creds := StoredCredentials{ Hostname: authHostname, APIHostname: finalAPIHostname, ClientID: clientID, @@ -364,41 +282,35 @@ func completeLogin(ctx context.Context, provider *oidc.Provider, clientID string EndpointTokenURL: provider.Endpoint().TokenURL, Scopes: scopes, Token: token, - UserName: claims.Name, // Store name - UserEmail: claims.Email, // Store email - Subject: claims.Subject, // Store subject (sub claim) + UserName: claims.Name, + UserEmail: claims.Email, + Subject: claims.Subject, } credsJSON, err := json.Marshal(creds) if err != nil { - return fmt.Errorf("failed to serialize credentials: %w", err) + return nil, fmt.Errorf("failed to serialize credentials: %w", err) } - err = keyring.Set(authutil.ServiceName, userKey, string(credsJSON)) - if err != nil { - return fmt.Errorf("failed to store credentials in keyring for user %s: %w", userKey, err) + if err := keyring.Set(ServiceName, userKey, string(credsJSON)); err != nil { + return nil, fmt.Errorf("failed to store credentials in keyring for user %s: %w", userKey, err) } - activeUserKey := "" // Temp variable to check if active user was set - err = keyring.Set(authutil.ServiceName, authutil.ActiveUserKey, userKey) - if err != nil { + activeUserSet := false + if err := keyring.Set(ServiceName, ActiveUserKey, userKey); err != nil { fmt.Printf("Warning: Failed to set '%s' as active user in keyring: %v\n", userKey, err) fmt.Printf("Credentials for '%s' were stored successfully.\n", userKey) } else { - // fmt.Printf("Credentials stored and set as active for user '%s'.\n", userKey) // Old message - activeUserKey = userKey // Mark success + activeUserSet = true } - // Update confirmation messages - if activeUserKey == userKey { // Check if we successfully set the active user + if activeUserSet { fmt.Println("Authentication successful. Credentials stored and set as active.") } else { - // This case handles if setting the active user key failed but creds were stored fmt.Println("Authentication successful. Credentials stored.") } - // Update the list of known users (using the new key format) - if err := addKnownUser(userKey); err != nil { + if err := AddKnownUserKey(userKey); err != nil { fmt.Printf("Warning: Failed to update list of known users: %v\n", err) } @@ -418,9 +330,17 @@ func completeLogin(ctx context.Context, provider *oidc.Provider, clientID string } } - return nil + return &LoginResult{ + UserKey: userKey, + UserEmail: claims.Email, + UserName: claims.Name, + Subject: claims.Subject, + APIHostname: finalAPIHostname, + }, nil } +// ---- Device flow ---- + type deviceAuthorizationResponse struct { DeviceCode string `json:"device_code"` UserCode string `json:"user_code"` @@ -469,12 +389,7 @@ func runDeviceFlow(ctx context.Context, providerURL string, clientID string, sco fmt.Println("Waiting for authorization...") - token, err := pollDeviceToken(ctx, tokenURL, clientID, deviceResp.DeviceCode, deviceResp.Interval, deviceResp.ExpiresIn) - if err != nil { - return nil, err - } - - return token, nil + return pollDeviceToken(ctx, tokenURL, clientID, deviceResp.DeviceCode, deviceResp.Interval, deviceResp.ExpiresIn) } func requestDeviceAuthorization(ctx context.Context, endpoint string, clientID string, scopes []string) (*deviceAuthorizationResponse, error) { @@ -543,7 +458,7 @@ func pollDeviceToken(ctx context.Context, tokenURL string, clientID string, devi case "": return token, nil case "authorization_pending": - // Keep polling + // Keep polling. case "slow_down": interval += 5 * time.Second case "access_denied": @@ -603,7 +518,6 @@ func requestDeviceToken(ctx context.Context, tokenURL string, clientID string, d AccessToken: tokenResp.AccessToken, TokenType: tokenResp.TokenType, RefreshToken: tokenResp.RefreshToken, - ExpiresIn: tokenResp.ExpiresIn, } if tokenResp.ExpiresIn > 0 { token.Expiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) @@ -616,47 +530,26 @@ func requestDeviceToken(ctx context.Context, tokenURL string, clientID string, d return token, "", nil } -// addKnownUser adds a userKey (now email@hostname) to the known_users list in the keyring. -func addKnownUser(newUserKey string) error { - knownUsers := []string{} - - // Get current list - knownUsersJSON, err := keyring.Get(authutil.ServiceName, authutil.KnownUsersKey) - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - // Only return error if it's not ErrNotFound - return fmt.Errorf("failed to get known users list from keyring: %w", err) - } +// ---- Crypto helpers ---- - if err == nil && knownUsersJSON != "" { - if err := json.Unmarshal([]byte(knownUsersJSON), &knownUsers); err != nil { - return fmt.Errorf("failed to unmarshal known users list: %w", err) - } - } - - // Check if user already exists - found := false - for _, key := range knownUsers { - if key == newUserKey { - found = true - break - } +func generateCodeVerifier() (string, error) { + const length = 64 + randomBytes := make([]byte, length) + if _, err := rand.Read(randomBytes); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) } + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(randomBytes), nil +} - // Add if not found - if !found { - knownUsers = append(knownUsers, newUserKey) - - // Marshal updated list - updatedJSON, err := json.Marshal(knownUsers) - if err != nil { - return fmt.Errorf("failed to marshal updated known users list: %w", err) - } +func generateCodeChallenge(verifier string) string { + hash := sha256.Sum256([]byte(verifier)) + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:]) +} - // Store updated list - if err := keyring.Set(authutil.ServiceName, authutil.KnownUsersKey, string(updatedJSON)); err != nil { - return fmt.Errorf("failed to store updated known users list: %w", err) - } +func generateRandomState(length int) (string, error) { + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + return "", err } - - return nil + return base64.RawURLEncoding.EncodeToString(b), nil } diff --git a/internal/authutil/machine_account_login.go b/internal/authutil/machine_account_login.go new file mode 100644 index 0000000..5650cae --- /dev/null +++ b/internal/authutil/machine_account_login.go @@ -0,0 +1,155 @@ +package authutil + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/coreos/go-oidc/v3/oidc" + + "go.datum.net/datumctl/internal/keyring" +) + +const defaultMachineAccountScope = "openid profile email offline_access" + +// RunMachineAccountLogin reads a machine account credentials file, discovers +// the token endpoint via OIDC, mints a JWT, exchanges it for an access token, +// and stores the resulting session in the keyring. Returns a LoginResult so the +// caller can build a v1beta1 Session. +func RunMachineAccountLogin(ctx context.Context, credentialsPath, hostname, apiHostname string, debug bool) (*LoginResult, error) { + data, err := os.ReadFile(credentialsPath) + if err != nil { + return nil, fmt.Errorf("failed to read credentials file %q: %w", credentialsPath, err) + } + + var creds MachineAccountCredentials + if err := json.Unmarshal(data, &creds); err != nil { + return nil, fmt.Errorf("failed to parse credentials file %q: %w", credentialsPath, err) + } + + if creds.Type != "datum_machine_account" { + return nil, fmt.Errorf("unsupported credentials type %q: expected \"datum_machine_account\"", creds.Type) + } + + var missing []string + if creds.ClientID == "" { + missing = append(missing, "client_id") + } + if creds.PrivateKeyID == "" { + missing = append(missing, "private_key_id") + } + if creds.PrivateKey == "" { + missing = append(missing, "private_key") + } + if len(missing) > 0 { + return nil, fmt.Errorf("credentials file is missing required fields: %s", strings.Join(missing, ", ")) + } + + providerURL := fmt.Sprintf("https://%s", hostname) + provider, err := oidc.NewProvider(ctx, providerURL) + if err != nil { + return nil, fmt.Errorf("failed to discover OIDC provider at %s: %w (pass --hostname to point datumctl at your Datum Cloud auth server)", providerURL, err) + } + tokenURI := provider.Endpoint().TokenURL + + scope := creds.Scope + if scope == "" { + scope = defaultMachineAccountScope + } + + finalAPIHostname := apiHostname + if finalAPIHostname == "" { + derived, err := DeriveAPIHostname(hostname) + if err != nil { + return nil, fmt.Errorf("failed to derive API hostname from auth hostname %q: %w", hostname, err) + } + finalAPIHostname = derived + } + + signedJWT, err := MintJWT(creds.ClientID, creds.PrivateKeyID, creds.PrivateKey, tokenURI) + if err != nil { + return nil, fmt.Errorf("failed to mint JWT: %w", err) + } + + if debug { + parts := strings.SplitN(signedJWT, ".", 3) + if len(parts) == 3 { + hdr, _ := base64.RawURLEncoding.DecodeString(parts[0]) + claims, _ := base64.RawURLEncoding.DecodeString(parts[1]) + fmt.Fprintf(os.Stderr, "\n--- JWT header ---\n%s\n", hdr) + fmt.Fprintf(os.Stderr, "--- JWT claims ---\n%s\n", claims) + } + fmt.Fprintf(os.Stderr, "\n--- Token request ---\nPOST %s\nassertion=%s...\n", tokenURI, signedJWT[:40]) + } + + token, err := ExchangeJWT(ctx, tokenURI, signedJWT, scope) + if err != nil { + return nil, fmt.Errorf("failed to exchange JWT for access token: %w", err) + } + + displayName := creds.ClientEmail + if displayName == "" { + displayName = creds.ClientID + } + + userKey := creds.ClientEmail + if userKey == "" { + userKey = creds.ClientID + } + + keyFilePath, err := WriteMachineAccountKeyFile(userKey, creds.PrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to write machine account private key to disk: %w", err) + } + + stored := StoredCredentials{ + Hostname: hostname, + APIHostname: finalAPIHostname, + ClientID: creds.ClientID, + EndpointTokenURL: tokenURI, + Token: token, + UserName: displayName, + UserEmail: creds.ClientEmail, + Subject: creds.ClientID, + CredentialType: "machine_account", + MachineAccount: &MachineAccountState{ + ClientEmail: creds.ClientEmail, + ClientID: creds.ClientID, + PrivateKeyID: creds.PrivateKeyID, + PrivateKeyPath: keyFilePath, + TokenURI: tokenURI, + Scope: scope, + }, + } + + credsJSON, err := json.Marshal(stored) + if err != nil { + return nil, fmt.Errorf("failed to serialize credentials: %w", err) + } + + if err := keyring.Set(ServiceName, userKey, string(credsJSON)); err != nil { + if cleanupErr := RemoveMachineAccountKeyFile(userKey); cleanupErr != nil { + fmt.Printf("Warning: failed to remove machine account key file after keyring error for %s: %v\n", userKey, cleanupErr) + } + return nil, fmt.Errorf("failed to store credentials in keyring for %s: %w", userKey, err) + } + + if err := keyring.Set(ServiceName, ActiveUserKey, userKey); err != nil { + fmt.Printf("Warning: Failed to set %q as active user in keyring: %v\n", userKey, err) + } + + if err := AddKnownUserKey(userKey); err != nil { + fmt.Printf("Warning: Failed to update list of known users: %v\n", err) + } + + return &LoginResult{ + UserKey: userKey, + UserEmail: creds.ClientEmail, + UserName: displayName, + Subject: creds.ClientID, + APIHostname: finalAPIHostname, + }, nil +} diff --git a/internal/client/factory.go b/internal/client/factory.go index da6e19d..e068548 100644 --- a/internal/client/factory.go +++ b/internal/client/factory.go @@ -2,11 +2,15 @@ package client import ( "context" + "encoding/base64" "fmt" "net/http" + "os" "github.com/spf13/pflag" "go.datum.net/datumctl/internal/authutil" + "go.datum.net/datumctl/internal/datumconfig" + "go.datum.net/datumctl/internal/miloapi" "golang.org/x/oauth2" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/client-go/rest" @@ -44,6 +48,10 @@ func (c *CustomConfigFlags) ToRESTConfig() (*rest.Config, error) { if err != nil { return nil, err } + ctxEntry, session, err := c.loadDatumContext() + if err != nil { + return nil, err + } if c.APIServer != nil && *c.APIServer != "" { config.Host = *c.APIServer } @@ -54,7 +62,11 @@ func (c *CustomConfigFlags) ToRESTConfig() (*rest.Config, error) { config.ServerName = *c.TLSServerName } - tknSrc, err := authutil.GetTokenSource(c.Context) + userKey, err := c.resolveUserKey(session) + if err != nil { + return nil, err + } + tknSrc, err := authutil.GetTokenSourceForUser(c.Context, userKey) if err != nil { return nil, err } @@ -63,35 +75,46 @@ func (c *CustomConfigFlags) ToRESTConfig() (*rest.Config, error) { return &oauth2.Transport{Source: tknSrc, Base: rt} } - apiHostname, err := authutil.GetAPIHostname() + baseServer, err := c.resolveBaseServer(userKey, session) if err != nil { return nil, err } - // Handle platform-wide mode - isPlatformWide := c.PlatformWide != nil && *c.PlatformWide - hasProject := c.Project != nil && *c.Project != "" - hasOrganization := c.Organization != nil && *c.Organization != "" + projectID, organizationID, platformWide, err := c.resolveScope(ctxEntry) + if err != nil { + return nil, err + } switch { - case isPlatformWide: - // Platform-wide mode: access the root of the platform - if hasProject || hasOrganization { - return nil, fmt.Errorf("--platform-wide cannot be used with --project or --organization") - } - config.Host = fmt.Sprintf("https://%s", apiHostname) - case !hasProject && !hasOrganization: - // No context specified - default behavior - case hasOrganization && !hasProject: - // Organization context - config.Host = fmt.Sprintf("https://%s/apis/resourcemanager.miloapis.com/v1alpha1/organizations/%s/control-plane", - apiHostname, *c.Organization) - case hasProject && !hasOrganization: - // Project context - config.Host = fmt.Sprintf("https://%s/apis/resourcemanager.miloapis.com/v1alpha1/projects/%s/control-plane", - apiHostname, *c.Project) + case platformWide: + config.Host = baseServer + case organizationID != "": + config.Host = miloapi.OrgControlPlaneURL(baseServer, organizationID) + case projectID != "": + config.Host = miloapi.ProjectControlPlaneURL(baseServer, projectID) default: - return nil, fmt.Errorf("exactly one of organizationID or projectID must be provided") + userID, err := authutil.GetUserIDFromTokenForUser(userKey) + if err != nil { + return nil, fmt.Errorf("failed to get user ID from token: %w", err) + } + config.Host = miloapi.UserControlPlaneURL(baseServer, userID) + } + + if session != nil { + ep := session.Endpoint + if (c.TLSServerName == nil || *c.TLSServerName == "") && ep.TLSServerName != "" { + config.ServerName = ep.TLSServerName + } + if (c.Insecure == nil || !*c.Insecure) && ep.InsecureSkipTLSVerify { + config.Insecure = true + } + if len(config.CAData) == 0 && ep.CertificateAuthorityData != "" { + decoded, err := base64.StdEncoding.DecodeString(ep.CertificateAuthorityData) + if err != nil { + return nil, fmt.Errorf("decode certificate authority data for session %q: %w", session.Name, err) + } + config.CAData = decoded + } } return config, nil @@ -127,14 +150,17 @@ func (c *CustomConfigFlags) ToRawKubeConfigLoader() clientcmd.ClientConfig { CurrentContext: "inmemory", } - // Create overrides from ConfigFlags - THIS IS THE KEY overrides := &clientcmd.ConfigOverrides{} if c.ConfigFlags.Namespace != nil && *c.ConfigFlags.Namespace != "" { overrides.Context.Namespace = *c.ConfigFlags.Namespace + } else { + ctxEntry, _, err := c.loadDatumContext() + if err == nil && ctxEntry != nil && ctxEntry.Namespace != "" { + overrides.Context.Namespace = ctxEntry.Namespace + } } - // Apply cluster overrides if set if c.APIServer != nil && *c.APIServer != "" { overrides.ClusterInfo.Server = *c.APIServer } @@ -158,6 +184,91 @@ func (c *CustomConfigFlags) ToRawKubeConfigLoader() clientcmd.ClientConfig { return clientcmd.NewDefaultClientConfig(*kubeConfig, overrides) } +// loadDatumContext resolves the active v1beta1 session and current context, +// if any. Returns (nil, nil, nil) when no session exists, letting callers +// fall back to the user-key path which bootstraps from keyring if needed. +func (c *CustomConfigFlags) loadDatumContext() (*datumconfig.DiscoveredContext, *datumconfig.Session, error) { + cfg, err := datumconfig.LoadAuto() + if err != nil { + return nil, nil, err + } + ctxEntry := cfg.CurrentContextEntry() + if ctxEntry == nil { + return nil, nil, nil + } + session := cfg.SessionByName(ctxEntry.Session) + return ctxEntry, session, nil +} + +func (c *CustomConfigFlags) resolveUserKey(session *datumconfig.Session) (string, error) { + if session != nil && session.UserKey != "" { + return session.UserKey, nil + } + return authutil.GetUserKey() +} + +func (c *CustomConfigFlags) resolveBaseServer(userKey string, session *datumconfig.Session) (string, error) { + if c.APIServer != nil && *c.APIServer != "" { + return datumconfig.CleanBaseServer(datumconfig.EnsureScheme(*c.APIServer)), nil + } + if session != nil && session.Endpoint.Server != "" { + return datumconfig.CleanBaseServer(datumconfig.EnsureScheme(session.Endpoint.Server)), nil + } + apiHostname, err := authutil.GetAPIHostnameForUser(userKey) + if err != nil { + return "", err + } + return datumconfig.CleanBaseServer(datumconfig.EnsureScheme(apiHostname)), nil +} + +// resolveScope picks the org/project/platform-wide scope for the request. It +// tries, in order: flags → environment variables → active context. Returns an +// error if the inputs are contradictory. +func (c *CustomConfigFlags) resolveScope(ctxEntry *datumconfig.DiscoveredContext) (string, string, bool, error) { + platformWide := c.PlatformWide != nil && *c.PlatformWide + projectID := "" + organizationID := "" + + if c.Project != nil && *c.Project != "" { + projectID = *c.Project + } + if c.Organization != nil && *c.Organization != "" { + organizationID = *c.Organization + } + + if platformWide && (projectID != "" || organizationID != "") { + return "", "", false, fmt.Errorf("--platform-wide cannot be used with --project or --organization") + } + + if projectID == "" && organizationID == "" && !platformWide { + if envProject := os.Getenv("DATUM_PROJECT"); envProject != "" { + projectID = envProject + } + if envOrg := os.Getenv("DATUM_ORGANIZATION"); envOrg != "" { + organizationID = envOrg + } + if projectID != "" && organizationID != "" { + return "", "", false, fmt.Errorf("DATUM_PROJECT and DATUM_ORGANIZATION cannot both be set") + } + } + + // Active context fills in when flags/env don't. Project scope wins over + // org scope when the context is project-scoped. + if projectID == "" && organizationID == "" && !platformWide && ctxEntry != nil { + if ctxEntry.ProjectID != "" { + projectID = ctxEntry.ProjectID + } else { + organizationID = ctxEntry.OrganizationID + } + } + + if projectID != "" && organizationID != "" { + return "", "", false, fmt.Errorf("exactly one of --project or --organization must be provided") + } + + return projectID, organizationID, platformWide, nil +} + func NewDatumFactory(ctx context.Context) (*DatumCloudFactory, error) { baseConfigFlags := genericclioptions.NewConfigFlags(true) baseConfigFlags = baseConfigFlags.WithWrapConfigFn(func(*rest.Config) *rest.Config { diff --git a/internal/client/user_context.go b/internal/client/user_context.go index 4ee79b2..79107c0 100644 --- a/internal/client/user_context.go +++ b/internal/client/user_context.go @@ -12,6 +12,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "go.datum.net/datumctl/internal/authutil" + "go.datum.net/datumctl/internal/datumconfig" + "go.datum.net/datumctl/internal/miloapi" ) // NewUserContextualClient creates a new controller-runtime client configured for the current user's context. @@ -30,24 +32,32 @@ func NewUserContextualClient(ctx context.Context) (client.Client, error) { } func NewRestConfig(ctx context.Context) (*rest.Config, error) { - tknSrc, err := authutil.GetTokenSource(ctx) + userKey, session, err := authutil.GetUserKeyForCurrentSession() + if err != nil { + return nil, fmt.Errorf("failed to get user key: %w", err) + } + tknSrc, err := authutil.GetTokenSourceForUser(ctx, userKey) if err != nil { return nil, fmt.Errorf("failed to get token source: %w", err) } // Get user ID from stored credentials - userID, err := authutil.GetUserIDFromToken(ctx) + userID, err := authutil.GetUserIDFromTokenForUser(userKey) if err != nil { return nil, fmt.Errorf("failed to get user ID from token: %w", err) } - // Get API hostname from stored credentials - apiHostname, err := authutil.GetAPIHostname() - if err != nil { - return nil, fmt.Errorf("failed to get API hostname: %w", err) + // Get API hostname — prefer session endpoint, fall back to credentials. + var apiHostname string + if session != nil && session.Endpoint.Server != "" { + apiHostname = datumconfig.StripScheme(session.Endpoint.Server) + } else { + apiHostname, err = authutil.GetAPIHostnameForUser(userKey) + if err != nil { + return nil, fmt.Errorf("failed to get API hostname: %w", err) + } } - // Build the user-contextual API endpoint - userContextAPI := fmt.Sprintf("https://%s/apis/iam.miloapis.com/v1alpha1/users/%s/control-plane", apiHostname, userID) + userContextAPI := miloapi.UserControlPlaneURL(apiHostname, userID) // Create Kubernetes client configuration with scheme config := &rest.Config{ @@ -63,3 +73,4 @@ func NewRestConfig(ctx context.Context) (*rest.Config, error) { return config, nil } + diff --git a/internal/cmd/auth/auth.go b/internal/cmd/auth/auth.go index 01205ab..4237776 100644 --- a/internal/cmd/auth/auth.go +++ b/internal/cmd/auth/auth.go @@ -14,10 +14,10 @@ func Command() *cobra.Command { user sessions, and retrieve tokens for scripting. Typical workflow: - 1. Log in: datumctl auth login + 1. Log in: datumctl login 2. Verify sessions: datumctl auth list 3. Switch accounts: datumctl auth switch - 4. Log out: datumctl auth logout + 4. Log out: datumctl logout [email] Advanced — kubectl integration: If you use kubectl and want to point it at a Datum Cloud control plane, @@ -26,9 +26,7 @@ Advanced — kubectl integration: cmd.AddCommand( getTokenCmd, - LoginCmd, listCmd, - logoutCmd, switchCmd, updateKubeconfigCmd(), ) diff --git a/internal/cmd/auth/get_token.go b/internal/cmd/auth/get_token.go index c54a443..5f24855 100644 --- a/internal/cmd/auth/get_token.go +++ b/internal/cmd/auth/get_token.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "go.datum.net/datumctl/internal/authutil" + "golang.org/x/oauth2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" clientauthv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1" ) @@ -56,25 +57,39 @@ Output formats (--output / -o): func init() { // Add flags for direct execution mode getTokenCmd.Flags().StringP("output", "o", outputFormatToken, fmt.Sprintf("Output format. One of: %s|%s", outputFormatToken, outputFormatK8sV1Creds)) + getTokenCmd.Flags().String("session", "", "Look up a specific session by name (defaults to the active session). Used by the kubectl exec plugin path so each kubeconfig entry pins to its own datumctl session.") } // runGetToken implements the logic based on the --output flag. func runGetToken(cmd *cobra.Command, args []string) error { ctx := cmd.Context() outputFormat, _ := cmd.Flags().GetString("output") // Ignore error, handled by validation + sessionName, _ := cmd.Flags().GetString("session") if outputFormat != outputFormatToken && outputFormat != outputFormatK8sV1Creds { // Return error here so Cobra prints usage return fmt.Errorf("invalid --output format %q. Must be %s or %s", outputFormat, outputFormatToken, outputFormatK8sV1Creds) } - // Get the token source (which handles refresh and persistence automatically) - tokenSource, err := authutil.GetTokenSource(ctx) - if err != nil { - if errors.Is(err, authutil.ErrNoActiveUser) { - return errors.New("no active user found in keyring. Please login first using 'datumctl auth login'") + var tokenSource oauth2.TokenSource + if sessionName != "" { + userKey, err := authutil.GetUserKeyForSession(sessionName) + if err != nil { + return err + } + tokenSource, err = authutil.GetTokenSourceForUser(ctx, userKey) + if err != nil { + return fmt.Errorf("failed to get token source: %w", err) + } + } else { + var err error + tokenSource, err = authutil.GetTokenSource(ctx) + if err != nil { + if errors.Is(err, authutil.ErrNoActiveUser) { + return errors.New("no active user found in keyring. Please login first using 'datumctl auth login'") + } + return fmt.Errorf("failed to get token source: %w", err) } - return fmt.Errorf("failed to get token source: %w", err) } // Get fresh token (will refresh if needed and persist automatically) diff --git a/internal/cmd/auth/list.go b/internal/cmd/auth/list.go index 1399340..297ad4b 100644 --- a/internal/cmd/auth/list.go +++ b/internal/cmd/auth/list.go @@ -1,112 +1,69 @@ package auth import ( - "encoding/json" - "errors" "fmt" "os" "github.com/rodaine/table" "github.com/spf13/cobra" - "go.datum.net/datumctl/internal/authutil" - "go.datum.net/datumctl/internal/keyring" + + "go.datum.net/datumctl/internal/datumconfig" ) var listCmd = &cobra.Command{ Use: "list", - Short: "List locally authenticated users", Aliases: []string{"ls"}, - Long: `Display a table of all Datum Cloud users whose credentials are stored -locally in the system keyring, along with their status. + Short: "List locally authenticated users", + Long: `Display a table of all Datum Cloud users whose sessions are stored +locally, along with their status. Columns: - Name The display name from the user's Datum Cloud account. - Email The email address used to log in. Pass this to 'datumctl auth switch' - or 'datumctl auth logout' to act on a specific account. - Status "Active" marks the account whose credentials are used by default - for all subsequent datumctl commands.`, + User The email address used to log in. Pass this to 'datumctl auth switch' + or 'datumctl logout' to act on a specific account. + Endpoint Shown only when sessions span more than one API endpoint. + Status "Active" marks the account whose credentials are used by default + for all subsequent datumctl commands.`, Example: ` # Show all logged-in users datumctl auth list # Alias datumctl auth ls`, - RunE: func(cmd *cobra.Command, args []string) error { - return runList() - }, + Args: cobra.NoArgs, + RunE: runList, } -func runList() error { - // Get the list of known user keys - knownUsers := []string{} - knownUsersJSON, err := keyring.Get(authutil.ServiceName, authutil.KnownUsersKey) +func runList(_ *cobra.Command, _ []string) error { + cfg, err := datumconfig.LoadAuto() if err != nil { - if errors.Is(err, keyring.ErrNotFound) { - // No users known yet - fmt.Println("No users have been logged in yet.") - return nil - } - // Other error getting the list - return fmt.Errorf("failed to get known users list from keyring: %w", err) - } - - if knownUsersJSON != "" { - if err := json.Unmarshal([]byte(knownUsersJSON), &knownUsers); err != nil { - return fmt.Errorf("failed to unmarshal known users list: %w", err) - } + return err } - if len(knownUsers) == 0 { - fmt.Println("No users have been logged in yet.") + if len(cfg.Sessions) == 0 { + fmt.Println("No authenticated users. Run 'datumctl login' to get started.") return nil } - // Get the active user key - activeUserKey, err := keyring.Get(authutil.ServiceName, authutil.ActiveUserKey) - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - // Don't fail if active user key is missing, just proceed without marking active - fmt.Printf("Warning: could not determine active user: %v\n", err) - activeUserKey = "" - } + showEndpoint := cfg.HasMultipleEndpoints() - // Initialize table - tbl := table.New("Name", "Email", "Status").WithWriter(os.Stdout) - - for _, userKey := range knownUsers { - // Retrieve the stored credentials for this user to get name/email - credsJSON, err := keyring.Get(authutil.ServiceName, userKey) - if err != nil { - // Add row with error message if details retrieval fails - tbl.AddRow("", userKey, fmt.Sprintf("Error: %v", err)) - continue - } - - var creds authutil.StoredCredentials - if err := json.Unmarshal([]byte(credsJSON), &creds); err != nil { - // Add row with error message if unmarshal fails - tbl.AddRow("", userKey, fmt.Sprintf("Error parsing: %v", err)) - continue - } + var tbl table.Table + if showEndpoint { + tbl = table.New("User", "Endpoint", "Status") + } else { + tbl = table.New("User", "Status") + } + tbl.WithWriter(os.Stdout) - // Prepare display values - displayName := creds.UserName - if displayName == "" { - displayName = "" - } - displayEmail := creds.UserEmail - if displayEmail == "" { - displayEmail = "" - } + for _, s := range cfg.Sessions { status := "" - if userKey == activeUserKey { + if s.Name == cfg.ActiveSession { status = "Active" } - - // Add row to table - tbl.AddRow(displayName, displayEmail, status) + if showEndpoint { + tbl.AddRow(s.UserEmail, datumconfig.StripScheme(s.Endpoint.Server), status) + } else { + tbl.AddRow(s.UserEmail, status) + } } - - // Print the table tbl.Print() - return nil } diff --git a/internal/cmd/auth/logout.go b/internal/cmd/auth/logout.go deleted file mode 100644 index 00b06e6..0000000 --- a/internal/cmd/auth/logout.go +++ /dev/null @@ -1,208 +0,0 @@ -package auth - -import ( - "encoding/json" - "errors" - "fmt" - - "github.com/spf13/cobra" - "go.datum.net/datumctl/internal/authutil" - "go.datum.net/datumctl/internal/keyring" -) - -var logoutAll bool // Flag variable for --all - -// logoutCmd removes local authentication credentials for a specified user or all users. -var logoutCmd = &cobra.Command{ - Use: "logout ", - Short: "Remove stored credentials for a user or all users", - Long: `Remove locally stored Datum Cloud credentials from the system keyring. - -Provide the email address of the user to log out (as shown by -'datumctl auth list'). Use --all to remove credentials for every -logged-in user at once. - -If you log out the currently active user, the active session is cleared. -You will need to run 'datumctl auth login' again before running commands -that require authentication.`, - Example: ` # Log out a specific user - datumctl auth logout user@example.com - - # Log out all authenticated users - datumctl auth logout --all`, - Args: func(cmd *cobra.Command, args []string) error { - // Custom args validation - all, _ := cmd.Flags().GetBool("all") - if all && len(args) > 0 { - return errors.New("cannot specify a user argument when using the --all flag") - } - if !all && len(args) != 1 { - return errors.New("must specify exactly one user email or use the --all flag") - } - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - if logoutAll { - return logoutAllUsers() - } else { - // Args validation ensures len(args) == 1 here - userKeyToLogout := args[0] - return logoutSingleUser(userKeyToLogout) // Renamed function - } - }, -} - -func init() { - // Add the --all flag - logoutCmd.Flags().BoolVar(&logoutAll, "all", false, "Log out all authenticated users") -} - -// logoutSingleUser handles logging out a specific user (previously runLogout) -func logoutSingleUser(userKeyToLogout string) error { - fmt.Printf("Logging out user: %s\n", userKeyToLogout) - - // 1. Get known users list - knownUsers := []string{} - knownUsersJSON, err := keyring.Get(authutil.ServiceName, authutil.KnownUsersKey) - - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - return fmt.Errorf("failed to get known users list from keyring: %w", err) - } - - if err == nil && knownUsersJSON != "" { - if err := json.Unmarshal([]byte(knownUsersJSON), &knownUsers); err != nil { - return fmt.Errorf("failed to unmarshal known users list: %w", err) - } - } - - // 2. Check if the user exists in the list and prepare updated list - found := false - updatedKnownUsers := []string{} - for _, key := range knownUsers { - if key == userKeyToLogout { - found = true - } else { - updatedKnownUsers = append(updatedKnownUsers, key) - } - } - - if !found { - fmt.Printf("User '%s' not found in locally stored credentials.\n", userKeyToLogout) - if err := keyring.Delete(authutil.ServiceName, userKeyToLogout); err != nil && !errors.Is(err, keyring.ErrNotFound) { - fmt.Printf("Warning: attempt to delete potential stray key for %s failed: %v\n", userKeyToLogout, err) - } - // Also remove any stray on-disk PEM file. This is the exact cleanup path - // users hit after a failed login left crypto material behind (issue #146). - if removeErr := authutil.RemoveMachineAccountKeyFile(userKeyToLogout); removeErr != nil { - fmt.Printf("Warning: failed to remove machine account key file for '%s': %v\n", userKeyToLogout, removeErr) - } - return nil - } - - // 3. Delete the user's specific credential entry - err = keyring.Delete(authutil.ServiceName, userKeyToLogout) - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - fmt.Printf("Warning: failed to delete credentials for user '%s' from keyring: %v\n", userKeyToLogout, err) - } - - // Remove the on-disk PEM key file for machine account sessions. - // This is a best-effort cleanup: ignore "not found" (interactive sessions - // never write a file) and only warn on other errors since the keyring entry - // is already gone. - if removeErr := authutil.RemoveMachineAccountKeyFile(userKeyToLogout); removeErr != nil { - fmt.Printf("Warning: failed to remove machine account key file for '%s': %v\n", userKeyToLogout, removeErr) - } - - // 4. Update and save the known users list - updatedJSON, err := json.Marshal(updatedKnownUsers) - if err != nil { - return fmt.Errorf("failed to marshal updated known users list: %w", err) - } - - err = keyring.Set(authutil.ServiceName, authutil.KnownUsersKey, string(updatedJSON)) - if err != nil { - return fmt.Errorf("failed to store updated known users list: %w", err) - } - - // 5. Check if the logged-out user was the active user - activeUserKey, err := keyring.Get(authutil.ServiceName, authutil.ActiveUserKey) - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - fmt.Printf("Warning: could not determine active user: %v\n", err) - } - - if activeUserKey == userKeyToLogout { - fmt.Println("Logging out the active user. Clearing active user setting.") - err = keyring.Delete(authutil.ServiceName, authutil.ActiveUserKey) - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - fmt.Printf("Warning: failed to clear active user setting from keyring: %v\n", err) - } - } - - fmt.Printf("Successfully logged out user '%s'.\n", userKeyToLogout) - return nil -} - -// logoutAllUsers handles logging out all known users -func logoutAllUsers() error { - fmt.Println("Logging out all users...") - - // 1. Get known users list - knownUsers := []string{} - knownUsersJSON, err := keyring.Get(authutil.ServiceName, authutil.KnownUsersKey) - if err != nil { - if errors.Is(err, keyring.ErrNotFound) { - fmt.Println("No users found in keyring to log out.") - return nil // Nothing to do - } - return fmt.Errorf("failed to get known users list from keyring: %w", err) - } - - if knownUsersJSON != "" { - if err := json.Unmarshal([]byte(knownUsersJSON), &knownUsers); err != nil { - return fmt.Errorf("failed to unmarshal known users list: %w", err) - } - } - - if len(knownUsers) == 0 { - fmt.Println("No users found in keyring to log out.") - return nil // Nothing to do - } - - // 2. Delete each user's specific credential entry - fmt.Printf("Found %d user(s) to log out.\n", len(knownUsers)) - logoutErrors := false - for _, userKey := range knownUsers { - err = keyring.Delete(authutil.ServiceName, userKey) - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - fmt.Printf("Warning: failed to delete credentials for user '%s' from keyring: %v\n", userKey, err) - logoutErrors = true // Mark that at least one error occurred - } - - // Remove the on-disk PEM key file for machine account sessions (best-effort). - if removeErr := authutil.RemoveMachineAccountKeyFile(userKey); removeErr != nil { - fmt.Printf("Warning: failed to remove machine account key file for '%s': %v\n", userKey, removeErr) - } - } - - // 3. Delete the known users list itself - err = keyring.Delete(authutil.ServiceName, authutil.KnownUsersKey) - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - fmt.Printf("Warning: failed to delete known users list from keyring: %v\n", err) - logoutErrors = true - } - - // 4. Delete the active user setting - err = keyring.Delete(authutil.ServiceName, authutil.ActiveUserKey) - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - fmt.Printf("Warning: failed to delete active user setting from keyring: %v\n", err) - logoutErrors = true - } - - if logoutErrors { - fmt.Println("Completed logout for all users, but encountered some errors (see warnings above).") - } else { - fmt.Println("Successfully logged out all users.") - } - - return nil -} diff --git a/internal/cmd/auth/machine_account_login.go b/internal/cmd/auth/machine_account_login.go deleted file mode 100644 index 4a21a7a..0000000 --- a/internal/cmd/auth/machine_account_login.go +++ /dev/null @@ -1,181 +0,0 @@ -package auth - -import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "os" - "strings" - - "github.com/coreos/go-oidc/v3/oidc" - - "go.datum.net/datumctl/internal/authutil" - "go.datum.net/datumctl/internal/keyring" -) - -// defaultMachineAccountScope is used when the credentials file does not -// specify a scope. The file's scope field is still honored for backward -// compatibility; new credentials files should omit it. -const defaultMachineAccountScope = "openid profile email offline_access" - -// runMachineAccountLogin handles the --credentials flag path for `datumctl auth login`. -// It reads a machine account credentials file, discovers the token endpoint via OIDC -// well-known config, mints a JWT, exchanges it for an initial access token, and stores -// the resulting session in the keyring. -// -// hostname is the auth server hostname (e.g., "auth.datum.net"), taken from the --hostname -// flag. apiHostname is the API server hostname; when empty, it is derived from hostname -// using authutil.DeriveAPIHostname. -func runMachineAccountLogin(ctx context.Context, credentialsPath, hostname, apiHostname string, debug bool) error { - data, err := os.ReadFile(credentialsPath) - if err != nil { - return fmt.Errorf("failed to read credentials file %q: %w", credentialsPath, err) - } - - var creds authutil.MachineAccountCredentials - if err := json.Unmarshal(data, &creds); err != nil { - return fmt.Errorf("failed to parse credentials file %q: %w", credentialsPath, err) - } - - // Validate type field. - if creds.Type != "datum_machine_account" { - return fmt.Errorf("unsupported credentials type %q: expected \"datum_machine_account\"", creds.Type) - } - - // Validate only the fields that cannot be discovered or derived. - missing := []string{} - if creds.ClientID == "" { - missing = append(missing, "client_id") - } - if creds.PrivateKeyID == "" { - missing = append(missing, "private_key_id") - } - if creds.PrivateKey == "" { - missing = append(missing, "private_key") - } - if len(missing) > 0 { - return fmt.Errorf("credentials file is missing required fields: %s", strings.Join(missing, ", ")) - } - - // Discover the token endpoint from the OIDC provider's well-known config. - // This mirrors the pattern used by the interactive login flow in login.go. - providerURL := fmt.Sprintf("https://%s", hostname) - provider, err := oidc.NewProvider(ctx, providerURL) - if err != nil { - return fmt.Errorf("failed to discover OIDC provider at %s: %w (pass --hostname to point datumctl at your Datum Cloud auth server)", providerURL, err) - } - tokenURI := provider.Endpoint().TokenURL - - // Resolve the scope to use. Honor the file's scope for backward compatibility; - // otherwise fall back to the default that mirrors the interactive login flow. - scope := creds.Scope - if scope == "" { - scope = defaultMachineAccountScope - } - - // Resolve the API hostname. Use the flag value when provided; otherwise derive - // it from the auth hostname using the same logic as the interactive login flow. - finalAPIHostname := apiHostname - if finalAPIHostname == "" { - derived, err := authutil.DeriveAPIHostname(hostname) - if err != nil { - return fmt.Errorf("failed to derive API hostname from auth hostname %q: %w", hostname, err) - } - finalAPIHostname = derived - } - - // Mint the initial JWT assertion using the discovered token URI. - signedJWT, err := authutil.MintJWT(creds.ClientID, creds.PrivateKeyID, creds.PrivateKey, tokenURI) - if err != nil { - return fmt.Errorf("failed to mint JWT: %w", err) - } - - if debug { - // Print JWT parts so the caller can inspect claims at jwt.io - parts := strings.SplitN(signedJWT, ".", 3) - if len(parts) == 3 { - hdr, _ := base64.RawURLEncoding.DecodeString(parts[0]) - claims, _ := base64.RawURLEncoding.DecodeString(parts[1]) - fmt.Fprintf(os.Stderr, "\n--- JWT header ---\n%s\n", hdr) - fmt.Fprintf(os.Stderr, "--- JWT claims ---\n%s\n", claims) - } - fmt.Fprintf(os.Stderr, "\n--- Token request ---\nPOST %s\nassertion=%s...\n", tokenURI, signedJWT[:40]) - } - - // Exchange for an access token using the discovered token URI. - token, err := authutil.ExchangeJWT(ctx, tokenURI, signedJWT, scope) - if err != nil { - return fmt.Errorf("failed to exchange JWT for access token: %w", err) - } - - // Determine the display name. Prefer client_email if present; fall back to client_id. - displayName := creds.ClientEmail - if displayName == "" { - displayName = creds.ClientID - } - - // Use client_email as the keyring key when available; fall back to client_id. - userKey := creds.ClientEmail - if userKey == "" { - userKey = creds.ClientID - } - - // Write the PEM private key to disk to keep the keyring blob small. - // On macOS the Keychain has a per-item size limit (~4 KB); embedding the - // PEM (~2.5 KB) alongside the access token pushes the blob over the limit. - keyFilePath, err := authutil.WriteMachineAccountKeyFile(userKey, creds.PrivateKey) - if err != nil { - return fmt.Errorf("failed to write machine account private key to disk: %w", err) - } - - stored := authutil.StoredCredentials{ - Hostname: hostname, - APIHostname: finalAPIHostname, - ClientID: creds.ClientID, - EndpointTokenURL: tokenURI, - Token: token, - UserName: displayName, - UserEmail: creds.ClientEmail, - Subject: creds.ClientID, - CredentialType: "machine_account", - MachineAccount: &authutil.MachineAccountState{ - ClientEmail: creds.ClientEmail, - ClientID: creds.ClientID, - PrivateKeyID: creds.PrivateKeyID, - // PrivateKey is intentionally left empty; the key lives on disk at - // PrivateKeyPath so the keyring blob stays under the macOS size limit. - PrivateKeyPath: keyFilePath, - // Store the discovered token URI and resolved scope so that the - // machineAccountTokenSource can refresh tokens without re-reading - // the credentials file. - TokenURI: tokenURI, - Scope: scope, - }, - } - - credsJSON, err := json.Marshal(stored) - if err != nil { - return fmt.Errorf("failed to serialize credentials: %w", err) - } - - if err := keyring.Set(authutil.ServiceName, userKey, string(credsJSON)); err != nil { - // The PEM key was written to disk but the keyring write failed. Remove the - // key file as best-effort cleanup so we don't leave crypto material behind. - if cleanupErr := authutil.RemoveMachineAccountKeyFile(userKey); cleanupErr != nil { - fmt.Printf("Warning: failed to remove machine account key file after keyring error for %s: %v\n", userKey, cleanupErr) - } - return fmt.Errorf("failed to store credentials in keyring for %s: %w", userKey, err) - } - - if err := keyring.Set(authutil.ServiceName, authutil.ActiveUserKey, userKey); err != nil { - fmt.Printf("Warning: Failed to set %q as active user in keyring: %v\n", userKey, err) - } - - if err := addKnownUser(userKey); err != nil { - fmt.Printf("Warning: Failed to update list of known users: %v\n", err) - } - - fmt.Printf("Authenticated as machine account: %s\n", displayName) - return nil -} diff --git a/internal/cmd/auth/switch.go b/internal/cmd/auth/switch.go index a4612c0..d8d7099 100644 --- a/internal/cmd/auth/switch.go +++ b/internal/cmd/auth/switch.go @@ -1,89 +1,108 @@ package auth import ( - "encoding/json" - "errors" "fmt" "github.com/spf13/cobra" - "go.datum.net/datumctl/internal/authutil" - "go.datum.net/datumctl/internal/keyring" + + "go.datum.net/datumctl/internal/datumconfig" + customerrors "go.datum.net/datumctl/internal/errors" + "go.datum.net/datumctl/internal/picker" ) var switchCmd = &cobra.Command{ - Use: "switch ", + Use: "switch [email]", Short: "Switch the active Datum Cloud user session", Long: `Change which locally stored user account is treated as active. The active user's credentials are used for all datumctl commands that require authentication. +If no email is provided, an interactive picker is shown. If the email +address matches sessions on multiple endpoints, you will be prompted to +choose. Each session remembers the last context you used, so switching +users also restores the context. + The email address must match an account shown by 'datumctl auth list'. -To add a new account, run 'datumctl auth login' first.`, - Example: ` # See which accounts are available - datumctl auth list +To add a new account, run 'datumctl login' first.`, + Example: ` # Interactive session picker + datumctl auth switch - # Switch to a different account + # Switch to a specific account datumctl auth switch user@example.com`, - Args: cobra.ExactArgs(1), // Requires exactly one argument: the user email - RunE: func(cmd *cobra.Command, args []string) error { - targetUserKey := args[0] - return runSwitch(targetUserKey) - }, + Args: cobra.MaximumNArgs(1), + RunE: runSwitch, } -func runSwitch(targetUserKey string) error { - // 1. Get the list of known users to validate the target user exists - knownUsers := []string{} - knownUsersJSON, err := keyring.Get(authutil.ServiceName, authutil.KnownUsersKey) - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - // Don't fail if list is missing, but we won't be able to validate. - // Print a warning? - fmt.Printf("Warning: could not retrieve known users list to validate target: %v\n", err) - } else if knownUsersJSON != "" { - if err := json.Unmarshal([]byte(knownUsersJSON), &knownUsers); err != nil { - // Also don't fail, but warn. - fmt.Printf("Warning: could not parse known users list to validate target: %v\n", err) - } +func runSwitch(_ *cobra.Command, args []string) error { + cfg, err := datumconfig.LoadAuto() + if err != nil { + return err } - // 2. Validate the target user key exists in the known list (if available) - found := false - if len(knownUsers) > 0 { - for _, key := range knownUsers { - if key == targetUserKey { - found = true - break - } + var sessionName string + + if len(args) == 0 { + // Interactive picker of all sessions. + if len(cfg.Sessions) == 0 { + return customerrors.NewUserErrorWithHint( + "No authenticated sessions.", + "Run 'datumctl login' to authenticate.", + ) } - if !found { - return fmt.Errorf("user '%s' not found in the list of locally authenticated users. Use 'datumctl auth list' to see available users", targetUserKey) + allSessions := make([]*datumconfig.Session, len(cfg.Sessions)) + for i := range cfg.Sessions { + allSessions[i] = &cfg.Sessions[i] } - } else { - // If known users list wasn't available or parseable, try to get the specific credential as a fallback validation - _, err := keyring.Get(authutil.ServiceName, targetUserKey) + sessionName, err = picker.SelectSession(allSessions) if err != nil { - if errors.Is(err, keyring.ErrNotFound) { - return fmt.Errorf("credentials for user '%s' not found. Use 'datumctl auth list' to see available users", targetUserKey) + return err + } + } else { + email := args[0] + sessions := cfg.SessionByEmail(email) + if len(sessions) == 0 { + return customerrors.NewUserErrorWithHint( + fmt.Sprintf("No sessions found for %s.", email), + "Run 'datumctl auth list' to see authenticated users, or 'datumctl login' to add a new one.", + ) + } + if len(sessions) == 1 { + sessionName = sessions[0].Name + } else { + sessionName, err = picker.SelectSession(sessions) + if err != nil { + return err } - return fmt.Errorf("failed to check credentials for user '%s': %w", targetUserKey, err) } } - // 3. Get current active user (optional, for comparison message) - currentActiveUser, _ := keyring.Get(authutil.ServiceName, authutil.ActiveUserKey) + session := cfg.SessionByName(sessionName) + if session == nil { + return fmt.Errorf("session %q not found", sessionName) + } + + cfg.ActiveSession = sessionName - if currentActiveUser == targetUserKey { - fmt.Printf("User '%s' is already the active user.\n", targetUserKey) - return nil + // Restore last context for this session. + if session.LastContext != "" { + if cfg.ContextByName(session.LastContext) != nil { + cfg.CurrentContext = session.LastContext + } } - // 4. Set the new active user - err = keyring.Set(authutil.ServiceName, authutil.ActiveUserKey, targetUserKey) - if err != nil { - return fmt.Errorf("failed to set '%s' as active user in keyring: %w", targetUserKey, err) + if err := datumconfig.SaveV1Beta1(cfg); err != nil { + return fmt.Errorf("save config: %w", err) + } + + fmt.Printf("\n\u2713 Switched to %s (%s)\n", session.UserName, session.UserEmail) + if ctxEntry := cfg.CurrentContextEntry(); ctxEntry != nil { + fmt.Printf(" Context: %s\n", datumconfig.FormatWithID(cfg.DisplayRef(ctxEntry), ctxEntry.Ref())) + } + if cfg.HasMultipleEndpoints() { + fmt.Printf(" Endpoint: %s\n", datumconfig.StripScheme(session.Endpoint.Server)) } - fmt.Printf("Switched active user to '%s'\n", targetUserKey) return nil } + diff --git a/internal/cmd/auth/update-kubeconfig.go b/internal/cmd/auth/update-kubeconfig.go index 0ceb469..57f9e9c 100644 --- a/internal/cmd/auth/update-kubeconfig.go +++ b/internal/cmd/auth/update-kubeconfig.go @@ -61,25 +61,31 @@ environments where the hostname cannot be derived from stored credentials).`, var apiHostname string var activeUserKey string + var sessionName string // Use override hostname if provided, otherwise get from stored credentials if hostname != "" { apiHostname = hostname } else { - var err error - apiHostname, err = authutil.GetAPIHostname() + userKey, session, err := authutil.GetUserKeyForCurrentSession() if err != nil { - return fmt.Errorf("failed to get API hostname: %w", err) - } - - activeUserKey, err = authutil.GetActiveUserKey() - if err != nil { - // We only expect an error here if the user is not logged in. if errors.Is(err, authutil.ErrNoActiveUser) { - return errors.New("no active user found. Please login using 'datumctl auth login'") + return errors.New("no active user found. Please login using 'datumctl login'") + } + return fmt.Errorf("failed to resolve active session: %w", err) + } + activeUserKey = userKey + if session != nil { + sessionName = session.Name + if session.Endpoint.Server != "" { + apiHostname = session.Endpoint.Server + } + } + if apiHostname == "" { + apiHostname, err = authutil.GetAPIHostnameForUser(userKey) + if err != nil { + return fmt.Errorf("failed to get API hostname: %w", err) } - // For other errors, provide more context. - return fmt.Errorf("failed to get active user for kubeconfig message: %w", err) } } @@ -122,15 +128,19 @@ environments where the hostname cannot be derived from stored credentials).`, AuthInfo: "datum-user", } cfg.CurrentContext = clusterName + execArgs := []string{ + "auth", + "get-token", + "--output=client.authentication.k8s.io/v1", + } + if sessionName != "" { + execArgs = append(execArgs, "--session="+sessionName) + } cfg.AuthInfos["datum-user"] = &api.AuthInfo{ Exec: &api.ExecConfig{ InstallHint: execPluginInstallHint, Command: executablePath, // Use absolute path - Args: []string{ - "auth", - "get-token", - "--output=client.authentication.k8s.io/v1", - }, + Args: execArgs, APIVersion: "client.authentication.k8s.io/v1", ProvideClusterInfo: false, InteractiveMode: "IfAvailable", diff --git a/internal/cmd/ctx/ctx.go b/internal/cmd/ctx/ctx.go new file mode 100644 index 0000000..b9a6393 --- /dev/null +++ b/internal/cmd/ctx/ctx.go @@ -0,0 +1,46 @@ +package ctx + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "go.datum.net/datumctl/internal/datumconfig" + "go.datum.net/datumctl/internal/discovery" +) + +// Command returns the "ctx" command group. Running "datumctl ctx" without a +// subcommand lists available contexts. +func Command() *cobra.Command { + var refresh bool + + cmd := &cobra.Command{ + Use: "ctx", + Short: "View and switch contexts", + Long: `List and switch between organizations and projects. + +Running 'datumctl ctx' without a subcommand lists all available contexts +for the current user. Use --refresh to update the context cache from the API.`, + Aliases: []string{"context"}, + RunE: func(cmd *cobra.Command, args []string) error { + if refresh { + if err := runRefresh(cmd); err != nil { + return err + } + } else { + cfg, err := datumconfig.LoadAuto() + if err == nil && discovery.IsCacheStale(cfg, discovery.DefaultStaleness) { + fmt.Fprintln(os.Stderr, "Hint: context cache may be stale. Run 'datumctl ctx --refresh' to update.") + } + } + return runList(cmd, args) + }, + } + + cmd.Flags().BoolVar(&refresh, "refresh", false, "Refresh the context cache from the API before listing") + + cmd.AddCommand(listCmd()) + cmd.AddCommand(useCmd()) + + return cmd +} diff --git a/internal/cmd/ctx/list.go b/internal/cmd/ctx/list.go new file mode 100644 index 0000000..1555b31 --- /dev/null +++ b/internal/cmd/ctx/list.go @@ -0,0 +1,99 @@ +package ctx + +import ( + "fmt" + "io" + "os" + "sort" + + "github.com/rodaine/table" + "github.com/spf13/cobra" + "go.datum.net/datumctl/internal/datumconfig" +) + +func listCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List available contexts", + Aliases: []string{"ls"}, + Args: cobra.NoArgs, + RunE: runList, + } +} + +func runList(_ *cobra.Command, _ []string) error { + cfg, err := datumconfig.LoadAuto() + if err != nil { + return err + } + + if len(cfg.Contexts) == 0 { + fmt.Println("No contexts available. Run 'datumctl login' to get started.") + return nil + } + + printContextTree(os.Stdout, cfg) + return nil +} + +type orgGroup struct { + orgID string + orgCtx *datumconfig.DiscoveredContext + projects []*datumconfig.DiscoveredContext +} + +func printContextTree(w io.Writer, cfg *datumconfig.ConfigV1Beta1) { + // Group contexts by org. + groups := make(map[string]*orgGroup) + var orgOrder []string + + for i := range cfg.Contexts { + ctx := &cfg.Contexts[i] + orgID := ctx.OrganizationID + + g, ok := groups[orgID] + if !ok { + g = &orgGroup{orgID: orgID} + groups[orgID] = g + orgOrder = append(orgOrder, orgID) + } + + if ctx.ProjectID == "" { + g.orgCtx = ctx + } else { + g.projects = append(g.projects, ctx) + } + } + + // Sort projects within each group. + for _, g := range groups { + sort.Slice(g.projects, func(i, j int) bool { + return g.projects[i].ProjectID < g.projects[j].ProjectID + }) + } + + tbl := table.New("Display Name", "Name", "Type", "Current") + tbl.WithWriter(w) + + for _, orgID := range orgOrder { + g := groups[orgID] + + if g.orgCtx != nil { + current := "" + if cfg.CurrentContext == g.orgCtx.Name { + current = "*" + } + tbl.AddRow(cfg.OrgDisplayName(orgID), orgID, "org", current) + } + + for _, p := range g.projects { + current := "" + if cfg.CurrentContext == p.Name { + current = "*" + } + tbl.AddRow(" "+cfg.ProjectDisplayName(p.ProjectID), p.Ref(), "project", current) + } + } + + tbl.Print() +} diff --git a/internal/cmd/ctx/refresh.go b/internal/cmd/ctx/refresh.go new file mode 100644 index 0000000..d637037 --- /dev/null +++ b/internal/cmd/ctx/refresh.go @@ -0,0 +1,41 @@ +package ctx + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl/internal/datumconfig" + "go.datum.net/datumctl/internal/discovery" + customerrors "go.datum.net/datumctl/internal/errors" +) + +func runRefresh(cmd *cobra.Command) error { + cfg, err := datumconfig.LoadAuto() + if err != nil { + return err + } + + session := cfg.ActiveSessionEntry() + if session == nil { + return customerrors.NewUserErrorWithHint( + "No active session.", + "Run 'datumctl login' to authenticate.", + ) + } + + fmt.Fprintln(os.Stderr, "Refreshing contexts...") + + count, err := discovery.RefreshSession(cmd.Context(), cfg, session) + if err != nil { + return err + } + + if err := datumconfig.SaveV1Beta1(cfg); err != nil { + return fmt.Errorf("save config: %w", err) + } + + fmt.Fprintf(os.Stderr, "\u2713 Discovered %d context(s)\n\n", count) + return nil +} diff --git a/internal/cmd/ctx/use.go b/internal/cmd/ctx/use.go new file mode 100644 index 0000000..b5d0d27 --- /dev/null +++ b/internal/cmd/ctx/use.go @@ -0,0 +1,71 @@ +package ctx + +import ( + "fmt" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl/internal/datumconfig" + customerrors "go.datum.net/datumctl/internal/errors" + "go.datum.net/datumctl/internal/picker" +) + +func useCmd() *cobra.Command { + return &cobra.Command{ + Use: "use [context]", + Short: "Switch the active context", + Long: `Switch the active context to an organization or project. + +If no argument is provided, an interactive picker is shown. +Use the format 'org/project' to select a project context, or just 'org' for an org context.`, + Args: cobra.MaximumNArgs(1), + RunE: runUse, + } +} + +func runUse(_ *cobra.Command, args []string) error { + cfg, err := datumconfig.LoadAuto() + if err != nil { + return err + } + + if len(cfg.Contexts) == 0 { + fmt.Println("No contexts available. Run 'datumctl login' to get started.") + return nil + } + + var resolved *datumconfig.DiscoveredContext + + if len(args) == 1 { + resolved = cfg.ResolveContext(args[0]) + if resolved == nil { + return customerrors.NewUserErrorWithHint( + fmt.Sprintf("Context %q not found.", args[0]), + "Run 'datumctl ctx' to see available contexts.", + ) + } + } else { + selected, err := picker.SelectContext(cfg.Contexts, cfg) + if err != nil { + return err + } + // Picker returns context Name directly — use exact lookup. + resolved = cfg.ContextByName(selected) + if resolved == nil { + return fmt.Errorf("selected context not found") + } + } + + cfg.CurrentContext = resolved.Name + + if s := cfg.SessionByName(resolved.Session); s != nil { + s.LastContext = resolved.Name + } + + if err := datumconfig.SaveV1Beta1(cfg); err != nil { + return fmt.Errorf("save config: %w", err) + } + + fmt.Printf("\n\u2713 Switched to %s\n", datumconfig.FormatWithID(cfg.DisplayRef(resolved), resolved.Ref())) + return nil +} diff --git a/internal/cmd/login/login.go b/internal/cmd/login/login.go new file mode 100644 index 0000000..a6ef477 --- /dev/null +++ b/internal/cmd/login/login.go @@ -0,0 +1,173 @@ +package login + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl/internal/authutil" + "go.datum.net/datumctl/internal/datumconfig" + "go.datum.net/datumctl/internal/discovery" + "go.datum.net/datumctl/internal/picker" +) + +var ( + hostname string + apiHostnameFlag string + clientIDFlag string + noBrowser bool + credentialsFile string + debugCredentials bool +) + +// Command returns the top-level "login" command that authenticates and selects +// a context in one step. +func Command() *cobra.Command { + cmd := &cobra.Command{ + Use: "login", + Short: "Log in to Datum Cloud and select a context", + Long: `Authenticate with Datum Cloud and discover your organizations and projects. +After authentication you will be prompted to select a default context (org +or project) so subsequent commands do not require --project or --organization +flags. + +By default, opens your browser for OAuth2 PKCE authentication. Use +--no-browser in headless environments (SSH, CI, containers) to authenticate +via a device-code flow that does not need a browser on this machine. + +Use --credentials to authenticate as a machine account (non-interactive).`, + Example: ` # Log in (opens browser, then picks a context) + datumctl login + + # Log in without a browser (device-code flow for headless/CI) + datumctl login --no-browser + + # Log in to a staging environment + datumctl login --hostname auth.staging.env.datum.net + + # Log in with a machine account credentials file + datumctl login --credentials ./my-key.json --hostname auth.staging.env.datum.net`, + RunE: runLogin, + } + + cmd.Flags().StringVar(&hostname, "hostname", "auth.datum.net", "Hostname of the Datum Cloud authentication server") + cmd.Flags().StringVar(&apiHostnameFlag, "api-hostname", "", "Hostname of the Datum Cloud API server (derived from auth hostname if omitted)") + cmd.Flags().StringVar(&clientIDFlag, "client-id", "", "Override the OAuth2 Client ID") + cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Use the device authorization flow instead of opening a browser") + cmd.Flags().StringVar(&credentialsFile, "credentials", "", "Path to a machine account credentials JSON file") + cmd.Flags().BoolVar(&debugCredentials, "debug", false, "Print JWT claims and token request details (credentials flow only)") + + return cmd +} + +func runLogin(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + var result *authutil.LoginResult + var authHostname string + + if credentialsFile != "" { + r, err := authutil.RunMachineAccountLogin(ctx, credentialsFile, hostname, apiHostnameFlag, debugCredentials) + if err != nil { + return err + } + result = r + authHostname = hostname + } else { + clientID, err := authutil.ResolveClientID(clientIDFlag, hostname) + if err != nil { + return err + } + r, err := authutil.RunInteractiveLogin(ctx, hostname, apiHostnameFlag, clientID, noBrowser, false) + if err != nil { + return err + } + result = r + authHostname = hostname + } + + fmt.Printf("\n\u2713 Authenticated as %s (%s)\n\n", result.UserName, result.UserEmail) + + cfg, err := datumconfig.LoadAuto() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + session := authutil.BuildSession(result, authHostname) + cfg.UpsertSession(session) + cfg.ActiveSession = session.Name + sessionName := session.Name + + tknSrc, err := authutil.GetTokenSourceForUser(ctx, result.UserKey) + if err != nil { + return fmt.Errorf("get token source: %w", err) + } + + apiHostname := result.APIHostname + + fmt.Print("Discovering organizations and projects...\n\n") + orgs, projects, err := discovery.FetchOrgsAndProjects(ctx, apiHostname, tknSrc, result.Subject) + if err != nil { + fmt.Printf("Warning: could not discover contexts: %v\n", err) + fmt.Println("\nYou can set a context manually with 'datumctl ctx use'.") + if saveErr := datumconfig.SaveV1Beta1(cfg); saveErr != nil { + return fmt.Errorf("save config: %w", saveErr) + } + return nil + } + + if len(orgs) > 0 { + fmt.Printf("You have access to %d organization(s):\n\n", len(orgs)) + for _, o := range orgs { + projCount := 0 + for _, p := range projects { + if p.OrgName == o.Name { + projCount++ + } + } + fmt.Printf(" %s (%d project(s))\n", o.Name, projCount) + } + fmt.Println() + } + + discovery.UpdateConfigCache(cfg, sessionName, orgs, projects) + + sessionContexts := cfg.ContextsForSession(sessionName) + if len(sessionContexts) == 0 { + fmt.Println("\nNo contexts available.") + if saveErr := datumconfig.SaveV1Beta1(cfg); saveErr != nil { + return fmt.Errorf("save config: %w", saveErr) + } + return nil + } + + selected, err := picker.SelectContext(sessionContexts, cfg) + if err != nil { + return err + } + + cfg.CurrentContext = selected + if s := cfg.SessionByName(sessionName); s != nil { + s.LastContext = selected + } + + if err := datumconfig.SaveV1Beta1(cfg); err != nil { + return fmt.Errorf("save config: %w", err) + } + + ctxEntry := cfg.ContextByName(selected) + if ctxEntry != nil { + fmt.Printf("\n\u2713 Context set to %s\n", datumconfig.FormatWithID(cfg.DisplayRef(ctxEntry), ctxEntry.Ref())) + } else { + fmt.Printf("\n\u2713 Context set to %s\n", selected) + } + return nil +} + +func deriveAuthHostname(apiHostname string) (string, error) { + if rest, ok := strings.CutPrefix(apiHostname, "api."); ok { + return "auth." + rest, nil + } + return "", fmt.Errorf("cannot derive auth hostname from '%s'; expected api.* prefix", apiHostname) +} diff --git a/internal/cmd/logout/logout.go b/internal/cmd/logout/logout.go new file mode 100644 index 0000000..59fc898 --- /dev/null +++ b/internal/cmd/logout/logout.go @@ -0,0 +1,128 @@ +package logout + +import ( + "encoding/json" + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl/internal/authutil" + "go.datum.net/datumctl/internal/datumconfig" + customerrors "go.datum.net/datumctl/internal/errors" + "go.datum.net/datumctl/internal/keyring" +) + +// Command returns the top-level "logout" command. +func Command() *cobra.Command { + var all bool + + cmd := &cobra.Command{ + Use: "logout [email]", + Short: "Remove local authentication credentials", + Long: `Remove local authentication credentials. + +Specify an email to log out sessions for that user. +Use --all to log out all users.`, + Args: func(cmd *cobra.Command, args []string) error { + allFlag, _ := cmd.Flags().GetBool("all") + if allFlag && len(args) > 0 { + return errors.New("cannot specify an email when using --all") + } + if !allFlag && len(args) != 1 { + return errors.New("specify an email or use --all") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if all { + return logoutAll() + } + return logoutByEmail(args[0]) + }, + } + + cmd.Flags().BoolVar(&all, "all", false, "Log out all authenticated users") + return cmd +} + +func logoutByEmail(email string) error { + cfg, err := datumconfig.LoadAuto() + if err != nil { + return err + } + + sessions := cfg.SessionByEmail(email) + if len(sessions) == 0 { + return customerrors.NewUserErrorWithHint( + fmt.Sprintf("No sessions found for %s.", email), + "Run 'datumctl auth list' to see authenticated users.", + ) + } + + for _, s := range sessions { + deleteKeyringEntry(s.UserKey) + } + + cfg.RemoveSessionsByEmail(email) + + // If current context is gone, clear it. + if cfg.CurrentContext != "" && cfg.ContextByName(cfg.CurrentContext) == nil { + cfg.CurrentContext = "" + } + + if err := datumconfig.SaveV1Beta1(cfg); err != nil { + return fmt.Errorf("save config: %w", err) + } + + fmt.Printf("\u2713 Logged out %s.\n", email) + return nil +} + +func logoutAll() error { + cfg, err := datumconfig.LoadAuto() + if err != nil { + return err + } + + for _, s := range cfg.Sessions { + deleteKeyringEntry(s.UserKey) + } + + cfg.Sessions = nil + cfg.Contexts = nil + cfg.CurrentContext = "" + cfg.ActiveSession = "" + + // Also clean up legacy keyring entries. + cleanupLegacyKeyring() + + if err := datumconfig.SaveV1Beta1(cfg); err != nil { + return fmt.Errorf("save config: %w", err) + } + + fmt.Println("\u2713 Logged out all users.") + return nil +} + +func deleteKeyringEntry(userKey string) { + if err := keyring.Delete(authutil.ServiceName, userKey); err != nil && !errors.Is(err, keyring.ErrNotFound) { + fmt.Fprintf(os.Stderr, "Warning: failed to delete credentials for %s: %v\n", userKey, err) + } +} + +func cleanupLegacyKeyring() { + // Clean up known_users list and active_user key. + knownUsersJSON, err := keyring.Get(authutil.ServiceName, authutil.KnownUsersKey) + if err == nil && knownUsersJSON != "" { + var knownUsers []string + if err := json.Unmarshal([]byte(knownUsersJSON), &knownUsers); err == nil { + for _, uk := range knownUsers { + deleteKeyringEntry(uk) + } + } + } + _ = keyring.Delete(authutil.ServiceName, authutil.KnownUsersKey) + _ = keyring.Delete(authutil.ServiceName, authutil.ActiveUserKey) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6db3d2c..c71acba 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -7,7 +7,11 @@ import ( "go.datum.net/datumctl/internal/client" "go.datum.net/datumctl/internal/cmd/auth" "go.datum.net/datumctl/internal/cmd/create" + datumctx "go.datum.net/datumctl/internal/cmd/ctx" "go.datum.net/datumctl/internal/cmd/docs" + "go.datum.net/datumctl/internal/cmd/login" + "go.datum.net/datumctl/internal/cmd/logout" + "go.datum.net/datumctl/internal/cmd/whoami" activity "go.miloapis.com/activity/pkg/cmd" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/kubectl/pkg/cmd/apiresources" @@ -51,9 +55,9 @@ projects, organizations, networking, compute, and more — directly from the terminal. No knowledge of Kubernetes or kubectl required. Get started: - datumctl auth login + datumctl login datumctl get organizations - datumctl get projects --organization `, + datumctl get dnszones`, } // kubectl version expects this flag to exist; add it here to avoid nil deref. rootCmd.PersistentFlags().Bool("warnings-as-errors", false, "Treat warnings as errors") @@ -88,9 +92,30 @@ Get started: ) rootCmd.AddGroup(&cobra.Group{ID: "auth", Title: "Authentication"}) + rootCmd.AddGroup(&cobra.Group{ID: "context", Title: "Context"}) rootCmd.AddGroup(&cobra.Group{ID: "other", Title: "Other Commands"}) rootCmd.AddGroup(&cobra.Group{ID: "resource", Title: "Resource Management"}) + // Top-level auth entry points. Promoted out of the 'auth' subgroup so that + // new users find 'datumctl login' at the root of the CLI, while experienced + // users can still reach 'datumctl auth login' for advanced options + // (machine-account, device flow). + loginCmd := login.Command() + loginCmd.GroupID = "auth" + rootCmd.AddCommand(loginCmd) + + logoutCmd := logout.Command() + logoutCmd.GroupID = "auth" + rootCmd.AddCommand(logoutCmd) + + whoamiCmd := whoami.Command() + whoamiCmd.GroupID = "auth" + rootCmd.AddCommand(whoamiCmd) + + ctxCmd := datumctx.Command() + ctxCmd.GroupID = "context" + rootCmd.AddCommand(ctxCmd) + authCommand := auth.Command() whoami := kubeauth.NewCmdWhoAmI(factory, ioStreams) whoami.Short = "Show your identity on a Datum Cloud control plane (kubectl users only)" diff --git a/internal/cmd/whoami/whoami.go b/internal/cmd/whoami/whoami.go new file mode 100644 index 0000000..3996f7e --- /dev/null +++ b/internal/cmd/whoami/whoami.go @@ -0,0 +1,82 @@ +package whoami + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl/internal/authutil" + "go.datum.net/datumctl/internal/datumconfig" +) + +// Command returns the top-level "whoami" command. +func Command() *cobra.Command { + return &cobra.Command{ + Use: "whoami", + Short: "Show the current user and context", + Args: cobra.NoArgs, + RunE: runWhoami, + } +} + +func runWhoami(_ *cobra.Command, _ []string) error { + cfg, err := datumconfig.LoadAuto() + if err != nil { + return err + } + + session := cfg.ActiveSessionEntry() + if session == nil { + return authutil.ErrNoActiveUser + } + + // Get user info from stored credentials for freshest data. + creds, err := authutil.GetStoredCredentials(session.UserKey) + if err != nil { + return fmt.Errorf("get credentials: %w", err) + } + + userName := creds.UserName + if userName == "" { + userName = session.UserName + } + userEmail := creds.UserEmail + if userEmail == "" { + userEmail = session.UserEmail + } + + fmt.Printf("User: %s (%s)\n", userName, userEmail) + + // Show endpoint only when multiple endpoints are in use. + if cfg.HasMultipleEndpoints() { + fmt.Printf("Endpoint: %s\n", datumconfig.StripScheme(session.Endpoint.Server)) + } + + ctxEntry := cfg.CurrentContextEntry() + if ctxEntry != nil { + fmt.Printf("Context: %s\n", ctxEntry.Ref()) + + fmt.Printf("Organization: %s\n", datumconfig.FormatWithID( + cfg.OrgDisplayName(ctxEntry.OrganizationID), ctxEntry.OrganizationID)) + + if ctxEntry.ProjectID != "" { + fmt.Printf("Project: %s\n", datumconfig.FormatWithID( + cfg.ProjectDisplayName(ctxEntry.ProjectID), ctxEntry.ProjectID)) + } + } else { + fmt.Println("Context: (none)") + fmt.Println(" Run 'datumctl ctx use' to select a context.") + } + + // Surface env-var overrides — these silently override the active context. + if v := os.Getenv("DATUM_PROJECT"); v != "" { + fmt.Printf("\nOverride: DATUM_PROJECT=%s (overrides context project)\n", v) + } + if v := os.Getenv("DATUM_ORGANIZATION"); v != "" { + fmt.Printf("\nOverride: DATUM_ORGANIZATION=%s (overrides context organization)\n", v) + } + + return nil +} + diff --git a/internal/datumconfig/config_v1beta1.go b/internal/datumconfig/config_v1beta1.go new file mode 100644 index 0000000..e35ea69 --- /dev/null +++ b/internal/datumconfig/config_v1beta1.go @@ -0,0 +1,554 @@ +package datumconfig + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "sigs.k8s.io/yaml" +) + +const ( + V1Beta1APIVersion = "datumctl.config.datum.net/v1beta1" +) + +// ConfigV1Beta1 is the v1beta1 config format with session-based auth and +// API-discovered contexts. +type ConfigV1Beta1 struct { + APIVersion string `json:"apiVersion" yaml:"apiVersion"` + Kind string `json:"kind" yaml:"kind"` + Sessions []Session `json:"sessions,omitempty" yaml:"sessions,omitempty"` + Contexts []DiscoveredContext `json:"contexts,omitempty" yaml:"contexts,omitempty"` + CurrentContext string `json:"current-context,omitempty" yaml:"current-context,omitempty"` + ActiveSession string `json:"active-session,omitempty" yaml:"active-session,omitempty"` + Cache ContextCache `json:"cache" yaml:"cache,omitempty"` +} + +// Session represents one authenticated login. Each login to an endpoint creates +// a session. This replaces the cluster + user entries from v1alpha1. +type Session struct { + Name string `json:"name" yaml:"name"` + UserKey string `json:"user-key" yaml:"user-key"` + UserEmail string `json:"user-email" yaml:"user-email"` + UserName string `json:"user-name,omitempty" yaml:"user-name,omitempty"` + Endpoint Endpoint `json:"endpoint" yaml:"endpoint"` + LastContext string `json:"last-context,omitempty" yaml:"last-context,omitempty"` +} + +// Endpoint holds connection details for an API server, bound to a login session. +type Endpoint struct { + Server string `json:"server" yaml:"server"` + AuthHostname string `json:"auth-hostname" yaml:"auth-hostname"` + TLSServerName string `json:"tls-server-name,omitempty" yaml:"tls-server-name,omitempty"` + InsecureSkipTLSVerify bool `json:"insecure-skip-tls-verify,omitempty" yaml:"insecure-skip-tls-verify,omitempty"` + CertificateAuthorityData string `json:"certificate-authority-data,omitempty" yaml:"certificate-authority-data,omitempty"` +} + +// DiscoveredContext is a context entry derived from the API. Names follow the +// format "orgID" for org-scoped or "orgID/projectID" for project-scoped. +type DiscoveredContext struct { + Name string `json:"name" yaml:"name"` + Session string `json:"session" yaml:"session"` + OrganizationID string `json:"organization-id" yaml:"organization-id"` + ProjectID string `json:"project-id,omitempty" yaml:"project-id,omitempty"` + Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` +} + +// Ref returns the canonical reference string for this context — "orgID" for org +// contexts or "orgID/projectID" for project contexts. This is the value users +// pass to "datumctl ctx use". +func (c *DiscoveredContext) Ref() string { + if c.ProjectID != "" { + return c.OrganizationID + "/" + c.ProjectID + } + return c.OrganizationID +} + +// FormatWithID returns "displayName (resourceID)" when the display name differs +// from the resource ID, or just the resource ID when they match. Used for +// consistent human-friendly output across commands. +func FormatWithID(displayName, resourceID string) string { + if displayName != "" && displayName != resourceID { + return fmt.Sprintf("%s (%s)", displayName, resourceID) + } + return resourceID +} + +// DisplayRef returns a human-friendly label for this context, using cached +// display names where available. Falls back to Ref() when no display names +// are cached. +func (c *ConfigV1Beta1) DisplayRef(ctx *DiscoveredContext) string { + orgLabel := ctx.OrganizationID + for _, o := range c.Cache.Organizations { + if o.ID == ctx.OrganizationID && o.DisplayName != "" { + orgLabel = o.DisplayName + break + } + } + if ctx.ProjectID == "" { + return orgLabel + } + projLabel := ctx.ProjectID + for _, p := range c.Cache.Projects { + if p.ID == ctx.ProjectID && p.DisplayName != "" { + projLabel = p.DisplayName + break + } + } + return orgLabel + "/" + projLabel +} + +// OrgDisplayName returns the cached display name for an org, or the ID if none. +func (c *ConfigV1Beta1) OrgDisplayName(orgID string) string { + for _, o := range c.Cache.Organizations { + if o.ID == orgID && o.DisplayName != "" { + return o.DisplayName + } + } + return orgID +} + +// ProjectDisplayName returns the cached display name for a project, or the ID if none. +func (c *ConfigV1Beta1) ProjectDisplayName(projectID string) string { + for _, p := range c.Cache.Projects { + if p.ID == projectID && p.DisplayName != "" { + return p.DisplayName + } + } + return projectID +} + +// ContextCache stores API-discovered orgs and projects with a staleness timestamp. +type ContextCache struct { + Organizations []CachedOrg `json:"organizations,omitempty" yaml:"organizations,omitempty"` + Projects []CachedProject `json:"projects,omitempty" yaml:"projects,omitempty"` + LastRefreshed *time.Time `json:"last-refreshed,omitempty" yaml:"last-refreshed,omitempty"` +} + +// CachedOrg is an API-discovered organization. +type CachedOrg struct { + ID string `json:"id" yaml:"id"` + DisplayName string `json:"display-name,omitempty" yaml:"display-name,omitempty"` +} + +// CachedProject is an API-discovered project under an org. +type CachedProject struct { + ID string `json:"id" yaml:"id"` + DisplayName string `json:"display-name,omitempty" yaml:"display-name,omitempty"` + OrgID string `json:"org-id" yaml:"org-id"` +} + +func NewV1Beta1() *ConfigV1Beta1 { + return &ConfigV1Beta1{ + APIVersion: V1Beta1APIVersion, + Kind: DefaultKind, + } +} + +func (c *ConfigV1Beta1) ensureDefaults() { + if c.APIVersion == "" { + c.APIVersion = V1Beta1APIVersion + } + if c.Kind == "" { + c.Kind = DefaultKind + } +} + +// SessionByName returns the session with the given name, or nil. +func (c *ConfigV1Beta1) SessionByName(name string) *Session { + for i := range c.Sessions { + if c.Sessions[i].Name == name { + return &c.Sessions[i] + } + } + return nil +} + +// SessionByUserKey returns the first session matching the given user key. +func (c *ConfigV1Beta1) SessionByUserKey(userKey string) *Session { + for i := range c.Sessions { + if c.Sessions[i].UserKey == userKey { + return &c.Sessions[i] + } + } + return nil +} + +// SessionByEmail returns all sessions matching the given email. +func (c *ConfigV1Beta1) SessionByEmail(email string) []*Session { + var sessions []*Session + for i := range c.Sessions { + if c.Sessions[i].UserEmail == email { + sessions = append(sessions, &c.Sessions[i]) + } + } + return sessions +} + +// ContextByName returns the context with the given name, or nil. +func (c *ConfigV1Beta1) ContextByName(name string) *DiscoveredContext { + for i := range c.Contexts { + if c.Contexts[i].Name == name { + return &c.Contexts[i] + } + } + return nil +} + +// ResolveContext finds a context by flexible matching. Resource IDs always +// take precedence over display names. It tries, in order: +// +// 1. Exact context name match +// 2. orgID/projectID match (for "org/project" queries) +// 3. orgID-only match for org-level contexts +// 4. projectID-only match if unambiguous +// 5. Display-name match on org + project (scoped together, only if unambiguous) +// 6. Display-name-only org or project match (unambiguous) +// +// Returns nil if no match, or if a display-name match is ambiguous. +func (c *ConfigV1Beta1) ResolveContext(query string) *DiscoveredContext { + // 1. Exact name match. + if ctx := c.ContextByName(query); ctx != nil { + return ctx + } + + orgPart, projPart, hasSlash := strings.Cut(query, "/") + + if hasSlash { + // 2. orgID/projectID match. + for i := range c.Contexts { + ctx := &c.Contexts[i] + if ctx.OrganizationID == orgPart && ctx.ProjectID == projPart { + return ctx + } + } + + // 5. Display-name match, scoped: resolve orgPart to an org ID first, + // then scope project display-name resolution to that org. + resolvedOrgIDs := c.resolveOrgIDs(orgPart) + if len(resolvedOrgIDs) == 0 { + // orgPart might already be a resource ID even though the slash path + // didn't match — allow it through as a search scope. + resolvedOrgIDs = []string{orgPart} + } + + var match *DiscoveredContext + for _, orgID := range resolvedOrgIDs { + projIDs := c.resolveProjectIDsInOrg(projPart, orgID) + // Also include projPart as a literal ID candidate within this org. + projIDs = appendUnique(projIDs, projPart) + for _, projID := range projIDs { + for i := range c.Contexts { + ctx := &c.Contexts[i] + if ctx.OrganizationID == orgID && ctx.ProjectID == projID { + if match != nil && match != ctx { + return nil // ambiguous + } + match = ctx + } + } + } + } + return match + } + + // 3. orgID-only match (org-level contexts). + for i := range c.Contexts { + ctx := &c.Contexts[i] + if ctx.OrganizationID == query && ctx.ProjectID == "" { + return ctx + } + } + + // 4. projectID-only match if unambiguous (resource IDs only, no display names). + var idMatch *DiscoveredContext + for i := range c.Contexts { + ctx := &c.Contexts[i] + if ctx.ProjectID == query { + if idMatch != nil { + return nil // ambiguous on resource ID + } + idMatch = ctx + } + } + if idMatch != nil { + return idMatch + } + + // 6a. Display-name-only org match (unambiguous). + resolvedOrgIDs := c.resolveOrgIDs(query) + if len(resolvedOrgIDs) == 1 { + for i := range c.Contexts { + ctx := &c.Contexts[i] + if ctx.OrganizationID == resolvedOrgIDs[0] && ctx.ProjectID == "" { + return ctx + } + } + } else if len(resolvedOrgIDs) > 1 { + return nil // ambiguous display name + } + + // 6b. Display-name-only project match (unambiguous). + resolvedProjIDs := c.resolveProjectIDs(query) + if len(resolvedProjIDs) == 1 { + for i := range c.Contexts { + ctx := &c.Contexts[i] + if ctx.ProjectID == resolvedProjIDs[0] { + return ctx + } + } + } + return nil +} + +// resolveOrgIDs returns all org resource IDs whose display name matches. +func (c *ConfigV1Beta1) resolveOrgIDs(displayName string) []string { + var ids []string + for _, o := range c.Cache.Organizations { + if o.DisplayName == displayName && o.DisplayName != o.ID { + ids = append(ids, o.ID) + } + } + return ids +} + +// resolveProjectIDs returns all project resource IDs whose display name matches. +func (c *ConfigV1Beta1) resolveProjectIDs(displayName string) []string { + var ids []string + for _, p := range c.Cache.Projects { + if p.DisplayName == displayName && p.DisplayName != p.ID { + ids = append(ids, p.ID) + } + } + return ids +} + +// resolveProjectIDsInOrg returns project resource IDs whose display name +// matches and which belong to the given org. +func (c *ConfigV1Beta1) resolveProjectIDsInOrg(displayName, orgID string) []string { + var ids []string + for _, p := range c.Cache.Projects { + if p.OrgID == orgID && p.DisplayName == displayName && p.DisplayName != p.ID { + ids = append(ids, p.ID) + } + } + return ids +} + +func appendUnique(s []string, v string) []string { + if slices.Contains(s, v) { + return s + } + return append(s, v) +} + +// CurrentContextEntry returns the active context, or nil if none is set. +func (c *ConfigV1Beta1) CurrentContextEntry() *DiscoveredContext { + if c.CurrentContext == "" { + return nil + } + return c.ContextByName(c.CurrentContext) +} + +// ActiveSessionEntry returns the active session. It first checks ActiveSession, +// then falls back to the session referenced by the current context. +func (c *ConfigV1Beta1) ActiveSessionEntry() *Session { + if c.ActiveSession != "" { + if s := c.SessionByName(c.ActiveSession); s != nil { + return s + } + } + ctx := c.CurrentContextEntry() + if ctx != nil { + return c.SessionByName(ctx.Session) + } + return nil +} + +// UpsertSession creates or updates a session by name. +func (c *ConfigV1Beta1) UpsertSession(s Session) { + for i := range c.Sessions { + if c.Sessions[i].Name == s.Name { + c.Sessions[i] = s + return + } + } + c.Sessions = append(c.Sessions, s) +} + +// UpsertContext creates or updates a context by name. +func (c *ConfigV1Beta1) UpsertContext(ctx DiscoveredContext) { + for i := range c.Contexts { + if c.Contexts[i].Name == ctx.Name { + c.Contexts[i] = ctx + return + } + } + c.Contexts = append(c.Contexts, ctx) +} + +// RemoveSession removes a session and all contexts referencing it. +func (c *ConfigV1Beta1) RemoveSession(name string) { + sessions := make([]Session, 0, len(c.Sessions)) + for _, s := range c.Sessions { + if s.Name != name { + sessions = append(sessions, s) + } + } + c.Sessions = sessions + + contexts := make([]DiscoveredContext, 0, len(c.Contexts)) + for _, ctx := range c.Contexts { + if ctx.Session != name { + contexts = append(contexts, ctx) + } + } + c.Contexts = contexts + + if c.ActiveSession == name { + c.ActiveSession = "" + } +} + +// RemoveSessionsByEmail removes all sessions (and their contexts) matching the email. +func (c *ConfigV1Beta1) RemoveSessionsByEmail(email string) { + sessionNames := make(map[string]bool) + sessions := make([]Session, 0, len(c.Sessions)) + for _, s := range c.Sessions { + if s.UserEmail == email { + sessionNames[s.Name] = true + } else { + sessions = append(sessions, s) + } + } + c.Sessions = sessions + + contexts := make([]DiscoveredContext, 0, len(c.Contexts)) + for _, ctx := range c.Contexts { + if !sessionNames[ctx.Session] { + contexts = append(contexts, ctx) + } + } + c.Contexts = contexts + + if sessionNames[c.ActiveSession] { + c.ActiveSession = "" + } + + // Clear current context if it belonged to a removed session. + if c.CurrentContext != "" { + if ctx := c.ContextByName(c.CurrentContext); ctx == nil { + c.CurrentContext = "" + } + } +} + +// ContextsForSession returns all contexts belonging to a session. +func (c *ConfigV1Beta1) ContextsForSession(sessionName string) []DiscoveredContext { + var result []DiscoveredContext + for _, ctx := range c.Contexts { + if ctx.Session == sessionName { + result = append(result, ctx) + } + } + return result +} + +// HasMultipleEndpoints returns true if sessions span more than one endpoint server. +func (c *ConfigV1Beta1) HasMultipleEndpoints() bool { + servers := make(map[string]bool) + for _, s := range c.Sessions { + servers[s.Endpoint.Server] = true + } + return len(servers) > 1 +} + +// LoadAuto loads the v1beta1 config from the default path. The name is kept +// for compatibility with earlier code that anticipated multi-version handling; +// today only v1beta1 is supported. +func LoadAuto() (*ConfigV1Beta1, error) { + return LoadV1Beta1() +} + +// LoadAutoFromPath loads the v1beta1 config from the given path. +func LoadAutoFromPath(path string) (*ConfigV1Beta1, error) { + return LoadV1Beta1FromPath(path) +} + +// LoadV1Beta1 loads a v1beta1 config from the default path. +func LoadV1Beta1() (*ConfigV1Beta1, error) { + path, err := DefaultPath() + if err != nil { + return nil, err + } + return LoadV1Beta1FromPath(path) +} + +// LoadV1Beta1FromPath loads a v1beta1 config from the given path. +func LoadV1Beta1FromPath(path string) (*ConfigV1Beta1, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return NewV1Beta1(), nil + } + return nil, fmt.Errorf("read config: %w", err) + } + + if len(strings.TrimSpace(string(data))) == 0 { + return NewV1Beta1(), nil + } + + cfg := NewV1Beta1() + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("unmarshal config: %w", err) + } + cfg.ensureDefaults() + return cfg, nil +} + +// SaveV1Beta1 saves a v1beta1 config to the default path. +func SaveV1Beta1(cfg *ConfigV1Beta1) error { + path, err := DefaultPath() + if err != nil { + return err + } + return SaveV1Beta1ToPath(cfg, path) +} + +// SaveV1Beta1ToPath saves a v1beta1 config to the given path. +func SaveV1Beta1ToPath(cfg *ConfigV1Beta1, path string) error { + if cfg == nil { + return errors.New("config is nil") + } + cfg.ensureDefaults() + + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("ensure config dir: %w", err) + } + + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("write temp config: %w", err) + } + + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("replace config: %w", err) + } + + return nil +} + +// SessionName generates a canonical session name from email and API hostname. +func SessionName(email, apiHostname string) string { + return fmt.Sprintf("%s@%s", email, StripScheme(apiHostname)) +} diff --git a/internal/datumconfig/config_v1beta1_test.go b/internal/datumconfig/config_v1beta1_test.go new file mode 100644 index 0000000..a8b5238 --- /dev/null +++ b/internal/datumconfig/config_v1beta1_test.go @@ -0,0 +1,776 @@ +package datumconfig + +import ( + "os" + "path/filepath" + "testing" +) + +// TestConfigV1Beta1RoundTrip verifies marshal/unmarshal round-trip preserves all fields. +func TestConfigV1Beta1RoundTrip(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + path := filepath.Join(tmp, "config") + + original := NewV1Beta1() + original.CurrentContext = "org-acme/proj-infra" + original.ActiveSession = "jane@acme.com@api.datum.net" + original.Sessions = []Session{ + { + Name: "jane@acme.com@api.datum.net", + UserKey: "key-abc123", + UserEmail: "jane@acme.com", + UserName: "Jane Doe", + Endpoint: Endpoint{ + Server: "https://api.datum.net", + AuthHostname: "auth.datum.net", + }, + LastContext: "org-acme/proj-infra", + }, + } + original.Contexts = []DiscoveredContext{ + { + Name: "org-acme", + Session: "jane@acme.com@api.datum.net", + OrganizationID: "org-acme", + }, + { + Name: "org-acme/proj-infra", + Session: "jane@acme.com@api.datum.net", + OrganizationID: "org-acme", + ProjectID: "proj-infra", + Namespace: "default", + }, + } + + if err := SaveV1Beta1ToPath(original, path); err != nil { + t.Fatalf("SaveV1Beta1ToPath: %v", err) + } + + loaded, err := LoadV1Beta1FromPath(path) + if err != nil { + t.Fatalf("LoadV1Beta1FromPath: %v", err) + } + + if loaded.APIVersion != V1Beta1APIVersion { + t.Errorf("APIVersion=%q, want %q", loaded.APIVersion, V1Beta1APIVersion) + } + if loaded.Kind != DefaultKind { + t.Errorf("Kind=%q, want %q", loaded.Kind, DefaultKind) + } + if loaded.CurrentContext != "org-acme/proj-infra" { + t.Errorf("CurrentContext=%q, want %q", loaded.CurrentContext, "org-acme/proj-infra") + } + if loaded.ActiveSession != "jane@acme.com@api.datum.net" { + t.Errorf("ActiveSession=%q, want %q", loaded.ActiveSession, "jane@acme.com@api.datum.net") + } + if len(loaded.Sessions) != 1 { + t.Fatalf("Sessions len=%d, want 1", len(loaded.Sessions)) + } + s := loaded.Sessions[0] + if s.Name != "jane@acme.com@api.datum.net" { + t.Errorf("Session.Name=%q, want %q", s.Name, "jane@acme.com@api.datum.net") + } + if s.UserKey != "key-abc123" { + t.Errorf("Session.UserKey=%q, want %q", s.UserKey, "key-abc123") + } + if s.UserEmail != "jane@acme.com" { + t.Errorf("Session.UserEmail=%q, want %q", s.UserEmail, "jane@acme.com") + } + if s.UserName != "Jane Doe" { + t.Errorf("Session.UserName=%q, want %q", s.UserName, "Jane Doe") + } + if s.Endpoint.Server != "https://api.datum.net" { + t.Errorf("Endpoint.Server=%q, want %q", s.Endpoint.Server, "https://api.datum.net") + } + if s.LastContext != "org-acme/proj-infra" { + t.Errorf("Session.LastContext=%q, want %q", s.LastContext, "org-acme/proj-infra") + } + if len(loaded.Contexts) != 2 { + t.Fatalf("Contexts len=%d, want 2", len(loaded.Contexts)) + } +} + +// TestSessionByName verifies lookup by session name. +func TestSessionByName(t *testing.T) { + t.Parallel() + + cfg := NewV1Beta1() + cfg.Sessions = []Session{ + {Name: "alice@api.datum.net", UserEmail: "alice@example.com"}, + {Name: "bob@api.datum.net", UserEmail: "bob@example.com"}, + } + + got := cfg.SessionByName("alice@api.datum.net") + if got == nil { + t.Fatal("SessionByName returned nil for known session") + } + if got.UserEmail != "alice@example.com" { + t.Errorf("UserEmail=%q, want %q", got.UserEmail, "alice@example.com") + } + + missing := cfg.SessionByName("nobody@api.datum.net") + if missing != nil { + t.Errorf("expected nil for unknown session, got %+v", missing) + } +} + +// TestContextByName verifies lookup by context name. +func TestContextByName(t *testing.T) { + t.Parallel() + + cfg := NewV1Beta1() + cfg.Contexts = []DiscoveredContext{ + {Name: "acme-corp", Session: "sess-1", OrganizationID: "org-1"}, + {Name: "acme-corp/web", Session: "sess-1", OrganizationID: "org-1", ProjectID: "proj-web"}, + } + + got := cfg.ContextByName("acme-corp/web") + if got == nil { + t.Fatal("ContextByName returned nil for known context") + } + if got.ProjectID != "proj-web" { + t.Errorf("ProjectID=%q, want %q", got.ProjectID, "proj-web") + } + + missing := cfg.ContextByName("nonexistent") + if missing != nil { + t.Errorf("expected nil for unknown context, got %+v", missing) + } +} + +// TestUpsertSession verifies insert and update behavior. +func TestUpsertSession(t *testing.T) { + t.Parallel() + + cfg := NewV1Beta1() + + // Insert new session. + cfg.UpsertSession(Session{Name: "sess-1", UserEmail: "user@example.com"}) + if len(cfg.Sessions) != 1 { + t.Fatalf("Sessions len=%d after insert, want 1", len(cfg.Sessions)) + } + + // Update existing session. + cfg.UpsertSession(Session{Name: "sess-1", UserEmail: "updated@example.com"}) + if len(cfg.Sessions) != 1 { + t.Fatalf("Sessions len=%d after update, want 1", len(cfg.Sessions)) + } + if cfg.Sessions[0].UserEmail != "updated@example.com" { + t.Errorf("UserEmail after update=%q, want %q", cfg.Sessions[0].UserEmail, "updated@example.com") + } + + // Insert a second distinct session. + cfg.UpsertSession(Session{Name: "sess-2", UserEmail: "other@example.com"}) + if len(cfg.Sessions) != 2 { + t.Fatalf("Sessions len=%d after second insert, want 2", len(cfg.Sessions)) + } +} + +// TestUpsertContext verifies insert and update behavior. +func TestUpsertContext(t *testing.T) { + t.Parallel() + + cfg := NewV1Beta1() + + // Insert new context. + cfg.UpsertContext(DiscoveredContext{Name: "acme-corp", Session: "sess-1", OrganizationID: "org-1"}) + if len(cfg.Contexts) != 1 { + t.Fatalf("Contexts len=%d after insert, want 1", len(cfg.Contexts)) + } + + // Update existing context. + cfg.UpsertContext(DiscoveredContext{Name: "acme-corp", Session: "sess-1", OrganizationID: "org-updated"}) + if len(cfg.Contexts) != 1 { + t.Fatalf("Contexts len=%d after update, want 1", len(cfg.Contexts)) + } + if cfg.Contexts[0].OrganizationID != "org-updated" { + t.Errorf("OrganizationID after update=%q, want %q", cfg.Contexts[0].OrganizationID, "org-updated") + } + + // Insert a second distinct context. + cfg.UpsertContext(DiscoveredContext{Name: "acme-corp/web", Session: "sess-1", OrganizationID: "org-1", ProjectID: "proj-web"}) + if len(cfg.Contexts) != 2 { + t.Fatalf("Contexts len=%d after second insert, want 2", len(cfg.Contexts)) + } +} + +// TestRemoveSession verifies that removing a session also removes its contexts +// and clears ActiveSession when it matches. +func TestRemoveSession(t *testing.T) { + t.Parallel() + + cfg := NewV1Beta1() + cfg.ActiveSession = "sess-1" + cfg.Sessions = []Session{ + {Name: "sess-1"}, + {Name: "sess-2"}, + } + cfg.Contexts = []DiscoveredContext{ + {Name: "acme-corp", Session: "sess-1"}, + {Name: "acme-corp/web", Session: "sess-1"}, + {Name: "other-org", Session: "sess-2"}, + } + + cfg.RemoveSession("sess-1") + + if len(cfg.Sessions) != 1 { + t.Errorf("Sessions len=%d after remove, want 1", len(cfg.Sessions)) + } + if cfg.Sessions[0].Name != "sess-2" { + t.Errorf("remaining session=%q, want %q", cfg.Sessions[0].Name, "sess-2") + } + + // Both contexts for sess-1 should be removed, the sess-2 one kept. + if len(cfg.Contexts) != 1 { + t.Errorf("Contexts len=%d after remove, want 1", len(cfg.Contexts)) + } + if cfg.Contexts[0].Name != "other-org" { + t.Errorf("remaining context=%q, want %q", cfg.Contexts[0].Name, "other-org") + } + + // ActiveSession should be cleared. + if cfg.ActiveSession != "" { + t.Errorf("ActiveSession=%q after remove, want empty", cfg.ActiveSession) + } +} + +// TestRemoveSessionDoesNotClearOtherActiveSession verifies ActiveSession is +// preserved when removing a different session. +func TestRemoveSessionDoesNotClearOtherActiveSession(t *testing.T) { + t.Parallel() + + cfg := NewV1Beta1() + cfg.ActiveSession = "sess-2" + cfg.Sessions = []Session{ + {Name: "sess-1"}, + {Name: "sess-2"}, + } + cfg.Contexts = []DiscoveredContext{ + {Name: "ctx-1", Session: "sess-1"}, + } + + cfg.RemoveSession("sess-1") + + if cfg.ActiveSession != "sess-2" { + t.Errorf("ActiveSession=%q, want %q", cfg.ActiveSession, "sess-2") + } +} + +// TestHasMultipleEndpoints verifies detection of multiple distinct endpoints. +func TestHasMultipleEndpoints(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sessions []Session + want bool + }{ + { + name: "no sessions", + sessions: nil, + want: false, + }, + { + name: "single session", + sessions: []Session{ + {Endpoint: Endpoint{Server: "https://api.datum.net"}}, + }, + want: false, + }, + { + name: "two sessions same endpoint", + sessions: []Session{ + {Endpoint: Endpoint{Server: "https://api.datum.net"}}, + {Endpoint: Endpoint{Server: "https://api.datum.net"}}, + }, + want: false, + }, + { + name: "two sessions different endpoints", + sessions: []Session{ + {Endpoint: Endpoint{Server: "https://api.datum.net"}}, + {Endpoint: Endpoint{Server: "https://api.staging.datum.net"}}, + }, + want: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cfg := NewV1Beta1() + cfg.Sessions = tt.sessions + if got := cfg.HasMultipleEndpoints(); got != tt.want { + t.Errorf("HasMultipleEndpoints()=%v, want %v", got, tt.want) + } + }) + } +} + +// TestActiveSessionEntry verifies fallback logic: explicit ActiveSession first, +// then session derived from current context. +func TestActiveSessionEntry(t *testing.T) { + t.Parallel() + + cfg := NewV1Beta1() + cfg.Sessions = []Session{ + {Name: "sess-1", UserEmail: "alice@example.com"}, + {Name: "sess-2", UserEmail: "bob@example.com"}, + } + cfg.Contexts = []DiscoveredContext{ + {Name: "acme-corp", Session: "sess-1"}, + } + + // No active session and no current context — should return nil. + got := cfg.ActiveSessionEntry() + if got != nil { + t.Errorf("expected nil when no ActiveSession and no CurrentContext, got %+v", got) + } + + // Set CurrentContext only — should fall back to context's session. + cfg.CurrentContext = "acme-corp" + got = cfg.ActiveSessionEntry() + if got == nil { + t.Fatal("expected session from current context, got nil") + } + if got.UserEmail != "alice@example.com" { + t.Errorf("UserEmail=%q, want %q", got.UserEmail, "alice@example.com") + } + + // Set explicit ActiveSession — should prefer it over context session. + cfg.ActiveSession = "sess-2" + got = cfg.ActiveSessionEntry() + if got == nil { + t.Fatal("expected session from ActiveSession, got nil") + } + if got.UserEmail != "bob@example.com" { + t.Errorf("UserEmail=%q, want %q", got.UserEmail, "bob@example.com") + } + + // ActiveSession set but nonexistent — falls back to current context. + cfg.ActiveSession = "nonexistent" + got = cfg.ActiveSessionEntry() + if got == nil { + t.Fatal("expected fallback to current context session, got nil") + } + if got.UserEmail != "alice@example.com" { + t.Errorf("fallback UserEmail=%q, want %q", got.UserEmail, "alice@example.com") + } +} + +// TestSessionNameGeneration verifies the canonical session name format. +func TestSessionNameGeneration(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + email string + apiHostname string + wantName string + }{ + { + name: "plain hostname", + email: "user@example.com", + apiHostname: "api.datum.net", + wantName: "user@example.com@api.datum.net", + }, + { + name: "https scheme stripped", + email: "user@example.com", + apiHostname: "https://api.datum.net", + wantName: "user@example.com@api.datum.net", + }, + { + name: "http scheme stripped", + email: "user@example.com", + apiHostname: "http://api.staging.datum.net", + wantName: "user@example.com@api.staging.datum.net", + }, + { + name: "trailing slash stripped", + email: "user@example.com", + apiHostname: "https://api.datum.net/", + wantName: "user@example.com@api.datum.net", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := SessionName(tt.email, tt.apiHostname) + if got != tt.wantName { + t.Errorf("SessionName(%q, %q)=%q, want %q", tt.email, tt.apiHostname, got, tt.wantName) + } + }) + } +} + +// TestContextsForSession verifies that only contexts belonging to the given +// session are returned. +func TestContextsForSession(t *testing.T) { + t.Parallel() + + cfg := NewV1Beta1() + cfg.Contexts = []DiscoveredContext{ + {Name: "acme-corp", Session: "sess-1"}, + {Name: "acme-corp/web", Session: "sess-1"}, + {Name: "other-org", Session: "sess-2"}, + } + + result := cfg.ContextsForSession("sess-1") + if len(result) != 2 { + t.Fatalf("ContextsForSession(sess-1) len=%d, want 2", len(result)) + } + for _, ctx := range result { + if ctx.Session != "sess-1" { + t.Errorf("unexpected session %q in results for sess-1", ctx.Session) + } + } + + result2 := cfg.ContextsForSession("sess-2") + if len(result2) != 1 { + t.Fatalf("ContextsForSession(sess-2) len=%d, want 1", len(result2)) + } + if result2[0].Name != "other-org" { + t.Errorf("context name=%q, want %q", result2[0].Name, "other-org") + } + + empty := cfg.ContextsForSession("nonexistent") + if len(empty) != 0 { + t.Errorf("expected empty slice for nonexistent session, got %d entries", len(empty)) + } +} + +// TestLoadV1Beta1FromPath_MissingFile verifies that a missing file returns a +// fresh default config (not an error). +func TestLoadV1Beta1FromPath_MissingFile(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + path := filepath.Join(tmp, "config") + + cfg, err := LoadV1Beta1FromPath(path) + if err != nil { + t.Fatalf("LoadV1Beta1FromPath: %v", err) + } + if cfg.APIVersion != V1Beta1APIVersion { + t.Errorf("APIVersion=%q, want %q", cfg.APIVersion, V1Beta1APIVersion) + } + if cfg.Kind != DefaultKind { + t.Errorf("Kind=%q, want %q", cfg.Kind, DefaultKind) + } +} + +// TestLoadV1Beta1FromPath_EmptyFile verifies that an empty file returns a +// fresh default config. +func TestLoadV1Beta1FromPath_EmptyFile(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + path := filepath.Join(tmp, "config") + + if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + cfg, err := LoadV1Beta1FromPath(path) + if err != nil { + t.Fatalf("LoadV1Beta1FromPath: %v", err) + } + if cfg.APIVersion != V1Beta1APIVersion { + t.Errorf("APIVersion=%q, want %q", cfg.APIVersion, V1Beta1APIVersion) + } +} + +// TestRef verifies the Ref() helper on DiscoveredContext. +func TestRef(t *testing.T) { + t.Parallel() + + orgCtx := DiscoveredContext{OrganizationID: "datum", ProjectID: ""} + if got := orgCtx.Ref(); got != "datum" { + t.Errorf("org Ref()=%q, want %q", got, "datum") + } + + projCtx := DiscoveredContext{OrganizationID: "datum", ProjectID: "datum-cloud"} + if got := projCtx.Ref(); got != "datum/datum-cloud" { + t.Errorf("project Ref()=%q, want %q", got, "datum/datum-cloud") + } +} + +// TestResolveContext verifies all six matching strategies. +func TestResolveContext(t *testing.T) { + t.Parallel() + + cfg := NewV1Beta1() + cfg.Contexts = []DiscoveredContext{ + {Name: "datum", OrganizationID: "datum"}, + {Name: "datum/datum-cloud", OrganizationID: "datum", ProjectID: "datum-cloud"}, + {Name: "datum/other-proj", OrganizationID: "datum", ProjectID: "other-proj"}, + {Name: "staging", OrganizationID: "staging"}, + {Name: "staging/my-app", OrganizationID: "staging", ProjectID: "my-app"}, + // A context with a legacy display-name-style name but correct IDs. + {Name: "Acme Corp/Web App", OrganizationID: "acme", ProjectID: "web-app"}, + } + + tests := []struct { + name string + query string + wantRef string // empty means nil expected + }{ + // 1. Exact name match. + {name: "exact org name", query: "datum", wantRef: "datum"}, + {name: "exact project name", query: "datum/datum-cloud", wantRef: "datum/datum-cloud"}, + {name: "exact legacy name", query: "Acme Corp/Web App", wantRef: "acme/web-app"}, + + // 2. orgID/projectID match (when name differs). + {name: "orgID/projectID for legacy context", query: "acme/web-app", wantRef: "acme/web-app"}, + + // 3. orgID-only match for org contexts. + {name: "orgID only", query: "staging", wantRef: "staging"}, + + // 4. projectID-only match (unambiguous). + {name: "unique projectID", query: "my-app", wantRef: "staging/my-app"}, + {name: "unique projectID web-app", query: "web-app", wantRef: "acme/web-app"}, + + // Ambiguous projectID — appears in zero project contexts with that exact ID. + // (datum-cloud is unique, so it resolves) + {name: "unique projectID datum-cloud", query: "datum-cloud", wantRef: "datum/datum-cloud"}, + + // No match. + {name: "no match", query: "nonexistent", wantRef: ""}, + {name: "no match with slash", query: "foo/bar", wantRef: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := cfg.ResolveContext(tt.query) + if tt.wantRef == "" { + if got != nil { + t.Errorf("ResolveContext(%q) = %q, want nil", tt.query, got.Ref()) + } + return + } + if got == nil { + t.Fatalf("ResolveContext(%q) = nil, want %q", tt.query, tt.wantRef) + } + if got.Ref() != tt.wantRef { + t.Errorf("ResolveContext(%q).Ref() = %q, want %q", tt.query, got.Ref(), tt.wantRef) + } + }) + } +} + +// TestResolveContext_AmbiguousProjectID verifies that ambiguous projectID +// returns nil. +func TestResolveContext_AmbiguousProjectID(t *testing.T) { + t.Parallel() + + cfg := NewV1Beta1() + cfg.Contexts = []DiscoveredContext{ + {Name: "org-a/shared", OrganizationID: "org-a", ProjectID: "shared"}, + {Name: "org-b/shared", OrganizationID: "org-b", ProjectID: "shared"}, + } + + got := cfg.ResolveContext("shared") + if got != nil { + t.Errorf("ResolveContext(\"shared\") should return nil for ambiguous match, got %q", got.Ref()) + } + + // But orgID/projectID should still resolve. + got = cfg.ResolveContext("org-a/shared") + if got == nil { + t.Fatal("ResolveContext(\"org-a/shared\") should resolve") + } + if got.Ref() != "org-a/shared" { + t.Errorf("got %q, want %q", got.Ref(), "org-a/shared") + } +} + +// TestResolveContext_DisplayNameMatching verifies display-name resolution. +func TestResolveContext_DisplayNameMatching(t *testing.T) { + t.Parallel() + + cfg := NewV1Beta1() + cfg.Cache.Organizations = []CachedOrg{ + {ID: "org-acme", DisplayName: "Acme Corp"}, + {ID: "org-datum", DisplayName: "Datum Technology, Inc."}, + } + cfg.Cache.Projects = []CachedProject{ + {ID: "proj-infra", DisplayName: "Infrastructure", OrgID: "org-acme"}, + {ID: "proj-web", DisplayName: "Web App", OrgID: "org-acme"}, + {ID: "proj-dc", DisplayName: "datum-cloud", OrgID: "org-datum"}, + } + cfg.Contexts = []DiscoveredContext{ + {Name: "org-acme", OrganizationID: "org-acme"}, + {Name: "org-acme/proj-infra", OrganizationID: "org-acme", ProjectID: "proj-infra"}, + {Name: "org-acme/proj-web", OrganizationID: "org-acme", ProjectID: "proj-web"}, + {Name: "org-datum", OrganizationID: "org-datum"}, + {Name: "org-datum/proj-dc", OrganizationID: "org-datum", ProjectID: "proj-dc"}, + } + + tests := []struct { + name string + query string + wantRef string + }{ + {name: "org display name only", query: "Acme Corp", wantRef: "org-acme"}, + {name: "project display name only", query: "Infrastructure", wantRef: "org-acme/proj-infra"}, + {name: "org/project both display names", query: "Acme Corp/Infrastructure", wantRef: "org-acme/proj-infra"}, + {name: "orgID/project display name", query: "org-acme/Web App", wantRef: "org-acme/proj-web"}, + {name: "org display name/projectID", query: "Acme Corp/proj-web", wantRef: "org-acme/proj-web"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := cfg.ResolveContext(tt.query) + if got == nil { + t.Fatalf("ResolveContext(%q) = nil, want %q", tt.query, tt.wantRef) + } + if got.Ref() != tt.wantRef { + t.Errorf("ResolveContext(%q).Ref() = %q, want %q", tt.query, got.Ref(), tt.wantRef) + } + }) + } +} + +// TestResolveContext_AmbiguousDisplayName verifies that ambiguous org/project +// display names return nil instead of silently picking the first match. +func TestResolveContext_AmbiguousDisplayName(t *testing.T) { + t.Parallel() + + cfg := NewV1Beta1() + cfg.Cache.Organizations = []CachedOrg{ + {ID: "org-a", DisplayName: "Production"}, + {ID: "org-b", DisplayName: "Production"}, + } + cfg.Contexts = []DiscoveredContext{ + {Name: "org-a", OrganizationID: "org-a"}, + {Name: "org-b", OrganizationID: "org-b"}, + } + + got := cfg.ResolveContext("Production") + if got != nil { + t.Errorf("ambiguous org display name should return nil, got %q", got.Ref()) + } +} + +// TestResolveContext_IDWinsOverDisplayName verifies that resource IDs always +// take precedence over display names, even when both could match. +func TestResolveContext_IDWinsOverDisplayName(t *testing.T) { + t.Parallel() + + cfg := NewV1Beta1() + cfg.Cache.Projects = []CachedProject{ + // Project B's DISPLAY NAME collides with project A's ID. + {ID: "proj-a", DisplayName: "something-else", OrgID: "org-1"}, + {ID: "proj-b", DisplayName: "proj-a", OrgID: "org-1"}, + } + cfg.Contexts = []DiscoveredContext{ + {Name: "org-1/proj-a", OrganizationID: "org-1", ProjectID: "proj-a"}, + {Name: "org-1/proj-b", OrganizationID: "org-1", ProjectID: "proj-b"}, + } + + // "proj-a" should match proj-a by ID, not proj-b by display name. + got := cfg.ResolveContext("proj-a") + if got == nil { + t.Fatal("ResolveContext(\"proj-a\") = nil, want proj-a") + } + if got.ProjectID != "proj-a" { + t.Errorf("ResolveContext(\"proj-a\") = %q, want proj-a (ID should win over display name)", got.ProjectID) + } +} + +// TestResolveContext_ProjectDisplayNameScopedToOrg verifies that a query like +// "someorg/projname" doesn't match a project with that display name in a +// different org. +func TestResolveContext_ProjectDisplayNameScopedToOrg(t *testing.T) { + t.Parallel() + + cfg := NewV1Beta1() + cfg.Cache.Projects = []CachedProject{ + {ID: "proj-a", DisplayName: "shared", OrgID: "org-a"}, + {ID: "proj-b", DisplayName: "shared", OrgID: "org-b"}, + } + cfg.Contexts = []DiscoveredContext{ + {Name: "org-a/proj-a", OrganizationID: "org-a", ProjectID: "proj-a"}, + {Name: "org-b/proj-b", OrganizationID: "org-b", ProjectID: "proj-b"}, + } + + // "org-a/shared" should resolve to proj-a, not proj-b. + got := cfg.ResolveContext("org-a/shared") + if got == nil { + t.Fatal("ResolveContext(\"org-a/shared\") = nil, want proj-a") + } + if got.ProjectID != "proj-a" { + t.Errorf("got %q, want proj-a (display-name resolution must be org-scoped)", got.ProjectID) + } + + // And "org-b/shared" should resolve to proj-b. + got = cfg.ResolveContext("org-b/shared") + if got == nil { + t.Fatal("ResolveContext(\"org-b/shared\") = nil, want proj-b") + } + if got.ProjectID != "proj-b" { + t.Errorf("got %q, want proj-b", got.ProjectID) + } +} + +// TestFormatWithID verifies the FormatWithID helper. +func TestFormatWithID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + displayName string + resourceID string + want string + }{ + {name: "display differs", displayName: "Acme Corp", resourceID: "org-acme", want: "Acme Corp (org-acme)"}, + {name: "display matches ID", displayName: "org-acme", resourceID: "org-acme", want: "org-acme"}, + {name: "empty display", displayName: "", resourceID: "org-acme", want: "org-acme"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := FormatWithID(tt.displayName, tt.resourceID); got != tt.want { + t.Errorf("FormatWithID(%q, %q) = %q, want %q", tt.displayName, tt.resourceID, got, tt.want) + } + }) + } +} + +// TestDisplayRef verifies the DisplayRef helper. +func TestDisplayRef(t *testing.T) { + t.Parallel() + + cfg := NewV1Beta1() + cfg.Cache.Organizations = []CachedOrg{ + {ID: "org-1", DisplayName: "Acme Corp"}, + } + cfg.Cache.Projects = []CachedProject{ + {ID: "proj-1", DisplayName: "Infra", OrgID: "org-1"}, + } + + orgCtx := &DiscoveredContext{OrganizationID: "org-1"} + if got := cfg.DisplayRef(orgCtx); got != "Acme Corp" { + t.Errorf("org DisplayRef = %q, want %q", got, "Acme Corp") + } + + projCtx := &DiscoveredContext{OrganizationID: "org-1", ProjectID: "proj-1"} + if got := cfg.DisplayRef(projCtx); got != "Acme Corp/Infra" { + t.Errorf("project DisplayRef = %q, want %q", got, "Acme Corp/Infra") + } + + // Missing display names — fall back to IDs. + orphan := &DiscoveredContext{OrganizationID: "unknown-org", ProjectID: "unknown-proj"} + if got := cfg.DisplayRef(orphan); got != "unknown-org/unknown-proj" { + t.Errorf("orphan DisplayRef = %q, want IDs", got) + } +} diff --git a/internal/datumconfig/util.go b/internal/datumconfig/util.go new file mode 100644 index 0000000..90bce9b --- /dev/null +++ b/internal/datumconfig/util.go @@ -0,0 +1,50 @@ +package datumconfig + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + DefaultKind = "DatumctlConfig" + DefaultNamespace = "default" +) + +// DefaultPath returns the canonical config file path (~/.datumctl/config). +func DefaultPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("get home dir: %w", err) + } + return filepath.Join(home, ".datumctl", "config"), nil +} + +// EnsureScheme adds an https:// scheme to server if none is present. +func EnsureScheme(server string) string { + if server == "" { + return server + } + if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") { + return server + } + return "https://" + server +} + +// CleanBaseServer strips a trailing slash from server. +func CleanBaseServer(server string) string { + if server == "" { + return server + } + return strings.TrimRight(server, "/") +} + +// StripScheme removes the https:// or http:// prefix and any trailing slash, +// returning the bare host (and optional path). +func StripScheme(s string) string { + s = strings.TrimPrefix(s, "https://") + s = strings.TrimPrefix(s, "http://") + s = strings.TrimSuffix(s, "/") + return s +} diff --git a/internal/discovery/cache.go b/internal/discovery/cache.go new file mode 100644 index 0000000..f865cd1 --- /dev/null +++ b/internal/discovery/cache.go @@ -0,0 +1,175 @@ +package discovery + +import ( + "context" + "fmt" + "time" + + "go.datum.net/datumctl/internal/authutil" + "go.datum.net/datumctl/internal/datumconfig" +) + +// DefaultStaleness is the cache age after which a warning is shown. +const DefaultStaleness = 24 * time.Hour + +// UpdateConfigCache is a convenience wrapper that performs a full refresh for +// a session: merges discovery into the cache, regenerates the session's +// contexts, and garbage-collects stale entries. +func UpdateConfigCache( + cfg *datumconfig.ConfigV1Beta1, + sessionName string, + orgs []DiscoveredOrg, + projects []DiscoveredProject, +) { + now := time.Now().UTC() + cfg.Cache.LastRefreshed = &now + + MergeCacheFromDiscovery(cfg, orgs, projects) + SyncContextsForSession(cfg, sessionName, orgs, projects) + GCCache(cfg) +} + +// MergeCacheFromDiscovery updates the cache with newly discovered orgs and +// projects. Existing entries are updated; unmentioned entries are preserved. +func MergeCacheFromDiscovery( + cfg *datumconfig.ConfigV1Beta1, + orgs []DiscoveredOrg, + projects []DiscoveredProject, +) { + for _, o := range orgs { + upsertCachedOrg(&cfg.Cache, datumconfig.CachedOrg{ + ID: o.Name, + DisplayName: o.DisplayName, + }) + } + for _, p := range projects { + upsertCachedProject(&cfg.Cache, datumconfig.CachedProject{ + ID: p.Name, + DisplayName: p.DisplayName, + OrgID: p.OrgName, + }) + } +} + +// SyncContextsForSession replaces all DiscoveredContext entries for the given +// session with fresh ones derived from the discovered orgs and projects. +// Contexts belonging to other sessions are preserved. +func SyncContextsForSession( + cfg *datumconfig.ConfigV1Beta1, + sessionName string, + orgs []DiscoveredOrg, + projects []DiscoveredProject, +) { + remaining := make([]datumconfig.DiscoveredContext, 0, len(cfg.Contexts)) + for _, ctx := range cfg.Contexts { + if ctx.Session != sessionName { + remaining = append(remaining, ctx) + } + } + cfg.Contexts = remaining + + for _, o := range orgs { + cfg.UpsertContext(datumconfig.DiscoveredContext{ + Name: o.Name, + Session: sessionName, + OrganizationID: o.Name, + }) + } + + for _, p := range projects { + cfg.UpsertContext(datumconfig.DiscoveredContext{ + Name: fmt.Sprintf("%s/%s", p.OrgName, p.Name), + Session: sessionName, + OrganizationID: p.OrgName, + ProjectID: p.Name, + Namespace: datumconfig.DefaultNamespace, + }) + } +} + +// GCCache removes cached orgs and projects that are no longer referenced by +// any DiscoveredContext in the config. This is safe to call at any time and +// correctly preserves entries referenced by other sessions' contexts. +func GCCache(cfg *datumconfig.ConfigV1Beta1) { + referencedOrgs := make(map[string]bool) + referencedProjects := make(map[string]bool) + for _, ctx := range cfg.Contexts { + if ctx.OrganizationID != "" { + referencedOrgs[ctx.OrganizationID] = true + } + if ctx.ProjectID != "" { + referencedProjects[ctx.ProjectID] = true + } + } + + keptOrgs := make([]datumconfig.CachedOrg, 0, len(cfg.Cache.Organizations)) + for _, o := range cfg.Cache.Organizations { + if referencedOrgs[o.ID] { + keptOrgs = append(keptOrgs, o) + } + } + cfg.Cache.Organizations = keptOrgs + + keptProjects := make([]datumconfig.CachedProject, 0, len(cfg.Cache.Projects)) + for _, p := range cfg.Cache.Projects { + if referencedProjects[p.ID] { + keptProjects = append(keptProjects, p) + } + } + cfg.Cache.Projects = keptProjects +} + +// RefreshSession re-runs API discovery for the given session and updates the +// config cache. Does not require re-authentication — uses the existing session +// credentials. Returns the number of contexts discovered. +func RefreshSession(ctx context.Context, cfg *datumconfig.ConfigV1Beta1, session *datumconfig.Session) (int, error) { + tknSrc, err := authutil.GetTokenSourceForUser(ctx, session.UserKey) + if err != nil { + return 0, fmt.Errorf("get token source: %w", err) + } + + userID, err := authutil.GetUserIDFromTokenForUser(session.UserKey) + if err != nil { + return 0, fmt.Errorf("get user ID: %w", err) + } + + apiHostname := datumconfig.StripScheme(session.Endpoint.Server) + + orgs, projects, err := FetchOrgsAndProjects(ctx, apiHostname, tknSrc, userID) + if err != nil { + return 0, fmt.Errorf("discover contexts: %w", err) + } + + UpdateConfigCache(cfg, session.Name, orgs, projects) + + return len(cfg.ContextsForSession(session.Name)), nil +} + +// IsCacheStale returns true if the cache has not been refreshed within the +// given duration, or if it has never been refreshed. +func IsCacheStale(cfg *datumconfig.ConfigV1Beta1, maxAge time.Duration) bool { + if cfg.Cache.LastRefreshed == nil { + return true + } + return time.Since(*cfg.Cache.LastRefreshed) > maxAge +} + +func upsertCachedOrg(cache *datumconfig.ContextCache, org datumconfig.CachedOrg) { + for i := range cache.Organizations { + if cache.Organizations[i].ID == org.ID { + cache.Organizations[i] = org + return + } + } + cache.Organizations = append(cache.Organizations, org) +} + +func upsertCachedProject(cache *datumconfig.ContextCache, proj datumconfig.CachedProject) { + for i := range cache.Projects { + if cache.Projects[i].ID == proj.ID { + cache.Projects[i] = proj + return + } + } + cache.Projects = append(cache.Projects, proj) +} diff --git a/internal/discovery/cache_test.go b/internal/discovery/cache_test.go new file mode 100644 index 0000000..e840bae --- /dev/null +++ b/internal/discovery/cache_test.go @@ -0,0 +1,333 @@ +package discovery + +import ( + "testing" + "time" + + "go.datum.net/datumctl/internal/datumconfig" +) + +// TestUpdateConfigCache_GeneratesOrgAndProjectContexts verifies that +// UpdateConfigCache creates org-level and project-level contexts using resource +// names (orgID, orgID/projectID). +func TestUpdateConfigCache_GeneratesOrgAndProjectContexts(t *testing.T) { + t.Parallel() + + cfg := datumconfig.NewV1Beta1() + sessionName := "alice@example.com@api.datum.net" + + orgs := []DiscoveredOrg{ + {Name: "org-acme", DisplayName: "acme-corp"}, + {Name: "org-personal", DisplayName: "personal"}, + } + projects := []DiscoveredProject{ + {Name: "proj-infra", DisplayName: "infra", OrgName: "org-acme"}, + {Name: "proj-web", DisplayName: "web-app", OrgName: "org-acme"}, + {Name: "proj-sandbox", DisplayName: "sandbox", OrgName: "org-personal"}, + } + + UpdateConfigCache(cfg, sessionName, orgs, projects) + + // Expect 2 org contexts + 3 project contexts = 5 total. + if len(cfg.Contexts) != 5 { + t.Fatalf("Contexts len=%d, want 5", len(cfg.Contexts)) + } + + // Verify org context uses resource name. + ctx := cfg.ContextByName("org-acme") + if ctx == nil { + t.Fatal("expected org context 'org-acme', not found") + } + if ctx.OrganizationID != "org-acme" { + t.Errorf("org-acme OrganizationID=%q, want %q", ctx.OrganizationID, "org-acme") + } + if ctx.ProjectID != "" { + t.Errorf("org-acme ProjectID=%q, want empty (org context)", ctx.ProjectID) + } + if ctx.Session != sessionName { + t.Errorf("org-acme Session=%q, want %q", ctx.Session, sessionName) + } + + // Verify project context uses "orgID/projectID" format. + projCtx := cfg.ContextByName("org-acme/proj-infra") + if projCtx == nil { + t.Fatal("expected project context 'org-acme/proj-infra', not found") + } + if projCtx.OrganizationID != "org-acme" { + t.Errorf("org-acme/proj-infra OrganizationID=%q, want %q", projCtx.OrganizationID, "org-acme") + } + if projCtx.ProjectID != "proj-infra" { + t.Errorf("org-acme/proj-infra ProjectID=%q, want %q", projCtx.ProjectID, "proj-infra") + } + if projCtx.Namespace != datumconfig.DefaultNamespace { + t.Errorf("org-acme/proj-infra Namespace=%q, want %q", projCtx.Namespace, datumconfig.DefaultNamespace) + } + + // Check cross-org project. + sandboxCtx := cfg.ContextByName("org-personal/proj-sandbox") + if sandboxCtx == nil { + t.Error("expected project context 'org-personal/proj-sandbox', not found") + } +} + +// TestUpdateConfigCache_FallsBackToIDWhenNoDisplayName verifies that resource +// names are used regardless of whether display names are present. +func TestUpdateConfigCache_FallsBackToIDWhenNoDisplayName(t *testing.T) { + t.Parallel() + + cfg := datumconfig.NewV1Beta1() + sessionName := "user@api.datum.net" + + orgs := []DiscoveredOrg{ + {Name: "org-123", DisplayName: ""}, + } + projects := []DiscoveredProject{ + {Name: "proj-456", DisplayName: "", OrgName: "org-123"}, + } + + UpdateConfigCache(cfg, sessionName, orgs, projects) + + orgCtx := cfg.ContextByName("org-123") + if orgCtx == nil { + t.Error("expected org context 'org-123', not found") + } + + projCtx := cfg.ContextByName("org-123/proj-456") + if projCtx == nil { + t.Error("expected project context 'org-123/proj-456', not found") + } +} + +// TestUpdateConfigCache_ReplacesExistingSessionContexts verifies that old +// contexts for the session are removed and replaced by new ones. +func TestUpdateConfigCache_ReplacesExistingSessionContexts(t *testing.T) { + t.Parallel() + + cfg := datumconfig.NewV1Beta1() + sessionName := "user@api.datum.net" + + // Seed with a stale context for this session and one for a different session. + cfg.UpsertContext(datumconfig.DiscoveredContext{ + Name: "stale-ctx", + Session: sessionName, + }) + cfg.UpsertContext(datumconfig.DiscoveredContext{ + Name: "other-session-ctx", + Session: "other-session", + }) + + orgs := []DiscoveredOrg{ + {Name: "org-new", DisplayName: "new-org"}, + } + UpdateConfigCache(cfg, sessionName, orgs, nil) + + // "stale-ctx" should be gone; "other-session-ctx" should remain. + if cfg.ContextByName("stale-ctx") != nil { + t.Error("stale-ctx should have been removed") + } + if cfg.ContextByName("other-session-ctx") == nil { + t.Error("other-session-ctx should have been preserved") + } + + // New context uses resource name, not display name. + if cfg.ContextByName("org-new") == nil { + t.Error("new org context 'org-new' should be present") + } +} + +// TestUpdateConfigCache_UpdatesCache verifies that the cache metadata +// (organizations, projects, LastRefreshed) is populated. +func TestUpdateConfigCache_UpdatesCache(t *testing.T) { + t.Parallel() + + cfg := datumconfig.NewV1Beta1() + sessionName := "user@api.datum.net" + + orgs := []DiscoveredOrg{ + {Name: "org-1", DisplayName: "Org One"}, + } + projects := []DiscoveredProject{ + {Name: "proj-1", DisplayName: "Project One", OrgName: "org-1"}, + } + + UpdateConfigCache(cfg, sessionName, orgs, projects) + + if len(cfg.Cache.Organizations) != 1 { + t.Fatalf("Cache.Organizations len=%d, want 1", len(cfg.Cache.Organizations)) + } + if cfg.Cache.Organizations[0].ID != "org-1" { + t.Errorf("CachedOrg.ID=%q, want %q", cfg.Cache.Organizations[0].ID, "org-1") + } + if cfg.Cache.Organizations[0].DisplayName != "Org One" { + t.Errorf("CachedOrg.DisplayName=%q, want %q", cfg.Cache.Organizations[0].DisplayName, "Org One") + } + + if len(cfg.Cache.Projects) != 1 { + t.Fatalf("Cache.Projects len=%d, want 1", len(cfg.Cache.Projects)) + } + if cfg.Cache.Projects[0].ID != "proj-1" { + t.Errorf("CachedProject.ID=%q, want %q", cfg.Cache.Projects[0].ID, "proj-1") + } + if cfg.Cache.Projects[0].OrgID != "org-1" { + t.Errorf("CachedProject.OrgID=%q, want %q", cfg.Cache.Projects[0].OrgID, "org-1") + } + + if cfg.Cache.LastRefreshed == nil { + t.Error("Cache.LastRefreshed should be set") + } +} + +// TestUpdateConfigCache_EmptyOrgsAndProjects verifies that calling with no +// orgs/projects clears all contexts for the session. +func TestUpdateConfigCache_EmptyOrgsAndProjects(t *testing.T) { + t.Parallel() + + cfg := datumconfig.NewV1Beta1() + sessionName := "user@api.datum.net" + + cfg.UpsertContext(datumconfig.DiscoveredContext{ + Name: "old-ctx", + Session: sessionName, + }) + + UpdateConfigCache(cfg, sessionName, nil, nil) + + if cfg.ContextByName("old-ctx") != nil { + t.Error("old-ctx should have been removed when updated with empty orgs/projects") + } + if len(cfg.Contexts) != 0 { + t.Errorf("Contexts len=%d, want 0", len(cfg.Contexts)) + } +} + +// TestUpdateConfigCache_MultiSession_PreservesOtherSessionCache verifies the +// GC fix: refreshing session-1's discovery must NOT wipe cache entries for orgs +// and projects that only session-2 knows about. +func TestUpdateConfigCache_MultiSession_PreservesOtherSessionCache(t *testing.T) { + t.Parallel() + + cfg := datumconfig.NewV1Beta1() + session1 := "user1@api.datum.net" + session2 := "user2@api.datum.net" + + // Pre-populate both sessions. + UpdateConfigCache(cfg, session1, []DiscoveredOrg{ + {Name: "org-prod", DisplayName: "Production"}, + }, []DiscoveredProject{ + {Name: "proj-p1", DisplayName: "Production Project", OrgName: "org-prod"}, + }) + UpdateConfigCache(cfg, session2, []DiscoveredOrg{ + {Name: "org-stg", DisplayName: "Staging"}, + }, []DiscoveredProject{ + {Name: "proj-s1", DisplayName: "Staging Project", OrgName: "org-stg"}, + }) + + // Sanity: both sessions' data is present. + if cfg.OrgDisplayName("org-prod") != "Production" { + t.Fatal("setup: Production cache missing") + } + if cfg.OrgDisplayName("org-stg") != "Staging" { + t.Fatal("setup: Staging cache missing") + } + + // Refresh session1 — should not affect session2's cache. + UpdateConfigCache(cfg, session1, []DiscoveredOrg{ + {Name: "org-prod", DisplayName: "Production"}, + }, []DiscoveredProject{ + {Name: "proj-p1", DisplayName: "Production Project", OrgName: "org-prod"}, + }) + + if cfg.OrgDisplayName("org-stg") != "Staging" { + t.Error("session2's org cache was wiped by session1 refresh") + } + if cfg.ProjectDisplayName("proj-s1") != "Staging Project" { + t.Error("session2's project cache was wiped by session1 refresh") + } +} + +// TestGCCache_RemovesUnreferencedEntries verifies that GCCache removes cache +// entries not referenced by any context. +func TestGCCache_RemovesUnreferencedEntries(t *testing.T) { + t.Parallel() + + cfg := datumconfig.NewV1Beta1() + cfg.Cache.Organizations = []datumconfig.CachedOrg{ + {ID: "org-kept", DisplayName: "Kept"}, + {ID: "org-orphan", DisplayName: "Orphan"}, + } + cfg.Cache.Projects = []datumconfig.CachedProject{ + {ID: "proj-kept", DisplayName: "Kept Project", OrgID: "org-kept"}, + {ID: "proj-orphan", DisplayName: "Orphan Project", OrgID: "org-orphan"}, + } + cfg.Contexts = []datumconfig.DiscoveredContext{ + {Name: "org-kept", OrganizationID: "org-kept"}, + {Name: "org-kept/proj-kept", OrganizationID: "org-kept", ProjectID: "proj-kept"}, + } + + GCCache(cfg) + + if cfg.OrgDisplayName("org-kept") != "Kept" { + t.Error("referenced org was removed") + } + if len(cfg.Cache.Organizations) != 1 { + t.Errorf("Cache.Organizations len=%d, want 1", len(cfg.Cache.Organizations)) + } + if len(cfg.Cache.Projects) != 1 { + t.Errorf("Cache.Projects len=%d, want 1", len(cfg.Cache.Projects)) + } +} + +// TestIsCacheStale verifies cache staleness detection. +func TestIsCacheStale(t *testing.T) { + t.Parallel() + + cfg := datumconfig.NewV1Beta1() + + // No refresh timestamp — always stale. + if !IsCacheStale(cfg, time.Hour) { + t.Error("empty cache should be stale") + } + + // Recently refreshed — not stale. + now := time.Now() + cfg.Cache.LastRefreshed = &now + if IsCacheStale(cfg, time.Hour) { + t.Error("recently refreshed cache should not be stale") + } + + // Old refresh — stale. + old := now.Add(-2 * time.Hour) + cfg.Cache.LastRefreshed = &old + if !IsCacheStale(cfg, time.Hour) { + t.Error("old cache should be stale") + } +} + +// TestMergeCacheFromDiscovery verifies the merge-only variant preserves +// existing entries and doesn't touch contexts. +func TestMergeCacheFromDiscovery(t *testing.T) { + t.Parallel() + + cfg := datumconfig.NewV1Beta1() + cfg.Cache.Organizations = []datumconfig.CachedOrg{ + {ID: "org-existing", DisplayName: "Old Name"}, + } + + MergeCacheFromDiscovery(cfg, []DiscoveredOrg{ + {Name: "org-existing", DisplayName: "New Name"}, + {Name: "org-new", DisplayName: "New Org"}, + }, nil) + + // Existing was updated. + if cfg.OrgDisplayName("org-existing") != "New Name" { + t.Errorf("existing org not updated, got %q", cfg.OrgDisplayName("org-existing")) + } + // New was added. + if cfg.OrgDisplayName("org-new") != "New Org" { + t.Errorf("new org not added") + } + // No contexts should have been created. + if len(cfg.Contexts) != 0 { + t.Errorf("merge should not touch contexts, got %d", len(cfg.Contexts)) + } +} diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go new file mode 100644 index 0000000..0bf5f17 --- /dev/null +++ b/internal/discovery/discovery.go @@ -0,0 +1,112 @@ +package discovery + +import ( + "context" + "fmt" + "net/http" + + resourcemanagerv1alpha1 "go.miloapis.com/milo/pkg/apis/resourcemanager/v1alpha1" + "golang.org/x/oauth2" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + "go.datum.net/datumctl/internal/miloapi" +) + +const displayNameAnnotation = "kubernetes.io/display-name" + +// DiscoveredOrg represents an organization the user has access to. +type DiscoveredOrg struct { + Name string // resource name (org ID) + DisplayName string // human-friendly name +} + +// DiscoveredProject represents a project under an organization. +type DiscoveredProject struct { + Name string // resource name (project ID) + DisplayName string // human-friendly name + OrgName string // owning organization resource name +} + +// FetchOrgsAndProjects discovers all organizations the user belongs to and +// their projects by querying the API. +func FetchOrgsAndProjects( + ctx context.Context, + apiHostname string, + tokenSource oauth2.TokenSource, + userID string, +) ([]DiscoveredOrg, []DiscoveredProject, error) { + scheme := runtime.NewScheme() + if err := resourcemanagerv1alpha1.AddToScheme(scheme); err != nil { + return nil, nil, fmt.Errorf("add resourcemanager types to scheme: %w", err) + } + + // List OrganizationMemberships from the user's IAM control plane. + userCPHost := miloapi.UserControlPlaneURL(apiHostname, userID) + userClient, err := newClient(userCPHost, tokenSource, scheme) + if err != nil { + return nil, nil, fmt.Errorf("create user control-plane client: %w", err) + } + + var memberships resourcemanagerv1alpha1.OrganizationMembershipList + if err := userClient.List(ctx, &memberships); err != nil { + return nil, nil, fmt.Errorf("list organization memberships: %w", err) + } + + var orgs []DiscoveredOrg + var projects []DiscoveredProject + + for _, m := range memberships.Items { + orgName := m.Spec.OrganizationRef.Name + displayName := m.Status.Organization.DisplayName + if displayName == "" { + displayName = orgName + } + + orgs = append(orgs, DiscoveredOrg{ + Name: orgName, + DisplayName: displayName, + }) + + // List projects in this org's control plane. + orgCPHost := miloapi.OrgControlPlaneURL(apiHostname, orgName) + orgClient, err := newClient(orgCPHost, tokenSource, scheme) + if err != nil { + return nil, nil, fmt.Errorf("create org control-plane client for %s: %w", orgName, err) + } + + var projectList resourcemanagerv1alpha1.ProjectList + if err := orgClient.List(ctx, &projectList); err != nil { + return nil, nil, fmt.Errorf("list projects for org %s: %w", orgName, err) + } + + for _, p := range projectList.Items { + projDisplayName := p.Annotations[displayNameAnnotation] + if projDisplayName == "" { + projDisplayName = p.Name + } + projects = append(projects, DiscoveredProject{ + Name: p.Name, + DisplayName: projDisplayName, + OrgName: orgName, + }) + } + } + + return orgs, projects, nil +} + +func newClient(host string, tokenSource oauth2.TokenSource, scheme *runtime.Scheme) (client.Client, error) { + cfg := &rest.Config{ + Host: host, + UserAgent: "datumctl", + WrapTransport: func(rt http.RoundTripper) http.RoundTripper { + return &oauth2.Transport{ + Source: tokenSource, + Base: rt, + } + }, + } + return client.New(cfg, client.Options{Scheme: scheme}) +} diff --git a/internal/miloapi/urls.go b/internal/miloapi/urls.go new file mode 100644 index 0000000..5c742e7 --- /dev/null +++ b/internal/miloapi/urls.go @@ -0,0 +1,37 @@ +// Package miloapi centralizes URL construction for Milo control-plane API paths. +// All datumctl callers should use these helpers instead of building paths by hand. +package miloapi + +import ( + "fmt" + + "go.datum.net/datumctl/internal/datumconfig" +) + +const ( + resourceManagerGroup = "resourcemanager.miloapis.com" + iamGroup = "iam.miloapis.com" + apiVersion = "v1alpha1" +) + +// UserControlPlaneURL returns the URL of a user's control plane. +func UserControlPlaneURL(baseServer, userID string) string { + return fmt.Sprintf("%s/apis/%s/%s/users/%s/control-plane", + normalizeBase(baseServer), iamGroup, apiVersion, userID) +} + +// OrgControlPlaneURL returns the URL of an organization's control plane. +func OrgControlPlaneURL(baseServer, orgID string) string { + return fmt.Sprintf("%s/apis/%s/%s/organizations/%s/control-plane", + normalizeBase(baseServer), resourceManagerGroup, apiVersion, orgID) +} + +// ProjectControlPlaneURL returns the URL of a project's control plane. +func ProjectControlPlaneURL(baseServer, projectID string) string { + return fmt.Sprintf("%s/apis/%s/%s/projects/%s/control-plane", + normalizeBase(baseServer), resourceManagerGroup, apiVersion, projectID) +} + +func normalizeBase(s string) string { + return datumconfig.CleanBaseServer(datumconfig.EnsureScheme(s)) +} diff --git a/internal/picker/picker.go b/internal/picker/picker.go new file mode 100644 index 0000000..80f445c --- /dev/null +++ b/internal/picker/picker.go @@ -0,0 +1,184 @@ +package picker + +import ( + "fmt" + "os" + "sort" + + "github.com/charmbracelet/huh" + "golang.org/x/term" + + "go.datum.net/datumctl/internal/datumconfig" + customerrors "go.datum.net/datumctl/internal/errors" +) + +// SelectContext presents an interactive picker for choosing a context. +// If only one context is available, it is auto-selected. Returns the context name. +func SelectContext(contexts []datumconfig.DiscoveredContext, cfg *datumconfig.ConfigV1Beta1) (string, error) { + if len(contexts) == 0 { + return "", customerrors.NewUserErrorWithHint( + "No contexts available.", + "Run 'datumctl login' to authenticate and discover your organizations and projects.", + ) + } + + if len(contexts) == 1 { + return contexts[0].Name, nil + } + + if !isTerminal() { + return "", customerrors.NewUserErrorWithHint( + "Interactive context selection requires a terminal.", + "Use --project or --organization flags, or set DATUM_PROJECT / DATUM_ORGANIZATION environment variables.", + ) + } + + options := buildContextOptions(contexts, cfg) + + var selected string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Select a context to work in"). + Options(options...). + Value(&selected). + Filtering(true), + ), + ) + + if err := form.Run(); err != nil { + return "", fmt.Errorf("context selection: %w", err) + } + + return selected, nil +} + +// buildContextOptions groups contexts by org and formats them with visual +// hierarchy: org entries appear as headers, projects are indented beneath. +func buildContextOptions(contexts []datumconfig.DiscoveredContext, cfg *datumconfig.ConfigV1Beta1) []huh.Option[string] { + // Separate orgs and projects, group projects by org ID. + type orgGroup struct { + orgCtx *datumconfig.DiscoveredContext + projects []datumconfig.DiscoveredContext + } + + groups := make(map[string]*orgGroup) + var orgOrder []string + + for i := range contexts { + ctx := &contexts[i] + if ctx.ProjectID == "" { + // Org-level context. + if _, ok := groups[ctx.OrganizationID]; !ok { + groups[ctx.OrganizationID] = &orgGroup{} + orgOrder = append(orgOrder, ctx.OrganizationID) + } + groups[ctx.OrganizationID].orgCtx = ctx + } + } + + for i := range contexts { + ctx := contexts[i] + if ctx.ProjectID != "" { + orgID := ctx.OrganizationID + if _, ok := groups[orgID]; !ok { + groups[orgID] = &orgGroup{} + orgOrder = append(orgOrder, orgID) + } + groups[orgID].projects = append(groups[orgID].projects, ctx) + } + } + + // Sort projects within each group by name. + for _, g := range groups { + sort.Slice(g.projects, func(i, j int) bool { + return g.projects[i].Name < g.projects[j].Name + }) + } + + // Build options with visual grouping. + var options []huh.Option[string] + for _, orgID := range orgOrder { + g := groups[orgID] + + // Org entry — show display name with resource name when they differ. + if g.orgCtx != nil { + label := datumconfig.FormatWithID(cfg.OrgDisplayName(orgID), orgID) + if cfg.CurrentContext == g.orgCtx.Name { + label += " *" + } + options = append(options, huh.NewOption(label, g.orgCtx.Name)) + } + + // Project entries, indented under their org. + for _, p := range g.projects { + label := " " + datumconfig.FormatWithID(cfg.ProjectDisplayName(p.ProjectID), p.ProjectID) + if cfg.CurrentContext == p.Name { + label += " *" + } + options = append(options, huh.NewOption(label, p.Name)) + } + } + + return options +} + +// SelectSession presents an interactive picker for disambiguating between +// sessions that share the same email. Returns the session name. +func SelectSession(sessions []*datumconfig.Session) (string, error) { + if len(sessions) == 0 { + return "", fmt.Errorf("no sessions to select from") + } + + if len(sessions) == 1 { + return sessions[0].Name, nil + } + + if !isTerminal() { + return "", customerrors.NewUserErrorWithHint( + "Multiple sessions found for this email. Interactive selection requires a terminal.", + "Run 'datumctl auth list' to see sessions and identify the email + endpoint to use.", + ) + } + + // Only show endpoint when sessions span multiple endpoints. + showEndpoint := false + if len(sessions) > 1 { + first := sessions[0].Endpoint.Server + for _, s := range sessions[1:] { + if s.Endpoint.Server != first { + showEndpoint = true + break + } + } + } + + options := make([]huh.Option[string], len(sessions)) + for i, s := range sessions { + label := s.UserEmail + if showEndpoint { + label = fmt.Sprintf("%s (%s)", s.UserEmail, datumconfig.StripScheme(s.Endpoint.Server)) + } + options[i] = huh.NewOption(label, s.Name) + } + + var selected string + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Which login session?"). + Options(options...). + Value(&selected), + ), + ) + + if err := form.Run(); err != nil { + return "", fmt.Errorf("session selection: %w", err) + } + + return selected, nil +} + +func isTerminal() bool { + return term.IsTerminal(int(os.Stdin.Fd())) +}