diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..88941da --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(git add:*)", + "Bash(git commit -m \"$\\(cat <<''EOF''\nUpdate System.CommandLine to 2.0.2 stable release\n\nThe System.CommandLine package was upgraded from the beta version to the\nstable 2.0.2 release, which introduced breaking API changes that required\nupdates across all CLI command files.\n\nAPI changes addressed:\n- AddCommand\\(\\) -> Subcommands.Add\\(\\) for adding subcommands\n- AddOption\\(\\) -> Options.Add\\(\\) for adding options\n- SetHandler\\(\\) extension -> SetAction\\(\\) method for command handlers\n- Option constructor signature changed: description is now set via\n Description property instead of constructor parameter\n- InvokeAsync\\(\\) on Command -> Parse\\(args\\).InvokeAsync\\(\\) in Program.cs\n\nFiles modified:\n- All command files under cli/src/Vdk/Commands/\n- cli/src/Vdk/Program.cs\n\nAlso normalizes line endings to LF per .gitattributes.\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n\\)\")" + ] + } +} diff --git a/ReadMe.md b/ReadMe.md index 7497c81..ac12cd8 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -31,20 +31,26 @@ For detailed installation instructions for your operating system, please refer t ## Quick Start -1. **Create a default cluster:** +1. **Login (device code flow):** ```bash - vdk create cluster + vega login + ``` + Follow the printed link/code to authenticate. Tokens are stored under `~/.vega/tokens`. + +2. **Create a default cluster:** + ```bash + vega create cluster ``` *(This may take a few minutes)* -2. **Verify cluster access:** +3. **Verify cluster access:** ```bash kubectl cluster-info --context kind-kind ``` -3. **Delete the cluster:** +4. **Delete the cluster:** ```bash - vdk delete cluster + vega delete cluster ``` ## Usage @@ -55,6 +61,17 @@ For comprehensive usage details, examples, and command references, please see th * **[Managing Clusters](./docs/usage/managing-clusters.md)** * **[Command Reference](./docs/usage/command-reference.md)** +## Authentication + +VDK enforces that you are logged in before executing most `vega` commands. Authentication uses an OAuth2 Device Code flow (Ory Hydra): + +* __Login__: `vega login [--profile ]` +* __Logout__: `vega logout [--profile ]` +* __Multi-profile__: Use `--profile` to login or logout different affiliations. The current profile pointer is stored in `~/.vega/tokens/.current_profile`. +* __Token storage__: Access/refresh tokens are stored per-profile in `~/.vega/tokens/.json`. Refresh is automatic when the access token expires. + +During cluster creation, VDK extracts `TenantId` from your access token and writes a ConfigMap named `vega-tenant` in the `vega-system` namespace so downstream tooling can correlate ownership. + ## Contributing We welcome contributions! Please read our **[Contribution Guidelines](./docs/contribution/guidelines.md)** and **[Development Setup](./docs/contribution/development-setup.md)** guides to get started. diff --git a/cli/src/Vdk/Commands/AppCommand.cs b/cli/src/Vdk/Commands/AppCommand.cs index 1a16a66..294bcdb 100644 --- a/cli/src/Vdk/Commands/AppCommand.cs +++ b/cli/src/Vdk/Commands/AppCommand.cs @@ -5,12 +5,14 @@ namespace Vdk.Commands; public class AppCommand : RootCommand { - public AppCommand(CreateCommand create, RemoveCommand remove, ListCommand list, InitializeCommand init, UpdateCommand update, IHubClient client) : base("Vega CLI - Manage Vega development environment") + public AppCommand(CreateCommand create, RemoveCommand remove, ListCommand list, InitializeCommand init, UpdateCommand update, LoginCommand login, LogoutCommand logout, IHubClient client) : base("Vega CLI - Manage Vega development environment") { Add(create); Add(remove); Add(list); Add(init); Add(update); + Add(login); + Add(logout); } } \ No newline at end of file diff --git a/cli/src/Vdk/Commands/CreateClusterCommand.cs b/cli/src/Vdk/Commands/CreateClusterCommand.cs index 034cc61..20c9976 100644 --- a/cli/src/Vdk/Commands/CreateClusterCommand.cs +++ b/cli/src/Vdk/Commands/CreateClusterCommand.cs @@ -13,6 +13,7 @@ public class CreateClusterCommand : Command { private readonly Func _clientFunc; private readonly GlobalConfiguration _configs; + private readonly IAuthService _auth; private readonly IConsole _console; private readonly IFileSystem _fileSystem; private readonly IFluxClient _flux; @@ -32,7 +33,8 @@ public CreateClusterCommand( IFluxClient flux, IReverseProxyClient reverseProxy, Func clientFunc, - GlobalConfiguration configs) + GlobalConfiguration configs, + IAuthService auth) : base("cluster", "Create a Vega development cluster") { _console = console; @@ -45,27 +47,33 @@ public CreateClusterCommand( _reverseProxy = reverseProxy; _clientFunc = clientFunc; _configs = configs; + _auth = auth; + var nameOption = new Option("--Name") { DefaultValueFactory = _ => Defaults.ClusterName, Description = "The name of the kind cluster to create." }; nameOption.Aliases.Add("-n"); var controlNodes = new Option("--ControlPlaneNodes") { DefaultValueFactory = _ => Defaults.ControlPlaneNodes, Description = "The number of control plane nodes in the cluster." }; controlNodes.Aliases.Add("-c"); var workers = new Option("--Workers") { DefaultValueFactory = _ => Defaults.WorkerNodes, Description = "The number of worker nodes in the cluster." }; workers.Aliases.Add("-w"); - var kubeVersion = new Option("--KubeVersion") { DefaultValueFactory = _ => "1.29", Description = "The kubernetes api version." }; + var kubeVersion = new Option("--KubeVersion") { DefaultValueFactory = _ => "", Description = "The kubernetes api version." }; kubeVersion.Aliases.Add("-k"); + var labels = new Option("--Labels") { DefaultValueFactory = _ => "", Description = "The labels to apply to the cluster to use in the configuration of Sectors. Each label pair should be separated by commas and the format should be KEY=VALUE. eg. KEY1=VAL1,KEY2=VAL2" }; + labels.Aliases.Add("-l"); Options.Add(nameOption); Options.Add(controlNodes); Options.Add(workers); Options.Add(kubeVersion); + Options.Add(labels); SetAction(parseResult => InvokeAsync( parseResult.GetValue(nameOption) ?? Defaults.ClusterName, parseResult.GetValue(controlNodes), parseResult.GetValue(workers), - parseResult.GetValue(kubeVersion))); + parseResult.GetValue(kubeVersion), + parseResult.GetValue(labels))); } - public async Task InvokeAsync(string name = Defaults.ClusterName, int controlPlaneNodes = 1, int workerNodes = 2, string? kubeVersionRequested = null) + public async Task InvokeAsync(string name = Defaults.ClusterName, int controlPlaneNodes = 1, int workerNodes = 2, string? kubeVersionRequested = null, string? labels = null) { // check if the hub and proxy are there if (!_reverseProxy.Exists()) @@ -73,6 +81,24 @@ public async Task InvokeAsync(string name = Defaults.ClusterName, int controlPla if (!_hub.ExistRegistry()) _hub.CreateRegistry(); + // validate the labels if they were passed in + var pairs = (labels??"").Split(',').Select(x=>x.Split('=')); + if (pairs.Any()) + { + //validate the labels and clean them up if needed + foreach (var pair in pairs) + { + pair[0] = pair[0].Trim(); + if (pair.Length > 1) + pair[1] = pair[1].Trim(); + if (pair.Length != 2 || string.IsNullOrWhiteSpace(pair[0]) || string.IsNullOrWhiteSpace(pair[1])) + { + _console.WriteError($"The provided label '{string.Join('=', pair)}' is not valid. Labels must be in the format KEY=VALUE and multiple labels must be separated by commas."); + return; + } + } + } + var map = await _kindVersionInfo.GetVersionInfoAsync(); string? kindVersion = null; try @@ -89,7 +115,7 @@ public async Task InvokeAsync(string name = Defaults.ClusterName, int controlPla _console.WriteWarning($"Kind version {kindVersion} is not supported by the current VDK."); return; } - var kubeVersion = kubeVersionRequested ?? await _kindVersionInfo.GetDefaultKubernetesVersionAsync(kindVersion); + var kubeVersion = string.IsNullOrWhiteSpace(kubeVersionRequested) ? await _kindVersionInfo.GetDefaultKubernetesVersionAsync(kindVersion) : kubeVersionRequested.Trim(); var image = map.FindImage(kindVersion, kubeVersion); if (image is null) { @@ -198,15 +224,56 @@ public async Task InvokeAsync(string name = Defaults.ClusterName, int controlPla { _reverseProxy.UpsertCluster(name.ToLower(), masterNode.ExtraPortMappings.First().HostPort, masterNode.ExtraPortMappings.Last().HostPort); - var ns = _clientFunc(name.ToLower()).Get("vega-system"); + var client = _clientFunc(name.ToLower()); + var ns = client.Get("vega-system"); ns.EnsureMetadata().EnsureAnnotations()[_configs.MasterNodeAnnotation] = _yaml.Serialize(masterNode); - _clientFunc(name.ToLower()).Update(ns); + client.Update(ns); + + // Write TenantId ConfigMap in vega-system + var tenantId = await _auth.GetTenantIdAsync(); + if (!string.IsNullOrWhiteSpace(tenantId)) + { + V1ConfigMap? cfg = null; + try + { + cfg = client.Get("vega-tenant", "vega-system"); + } + catch { /* not found, will create */ } + + if (cfg is null) + { + cfg = new V1ConfigMap + { + Metadata = new V1ObjectMeta { Name = "vega-tenant", NamespaceProperty = "vega-system" }, + Data = new Dictionary { ["TenantId"] = tenantId } + }; + client.Create(cfg); + } + else + { + cfg.Data ??= new Dictionary(); + cfg.Data["TenantId"] = tenantId; + // add the label pairs here + foreach (var pair in pairs) + { + if (pair.Length == 2 && !string.IsNullOrWhiteSpace(pair[0]) && !string.IsNullOrWhiteSpace(pair[1])) + { + cfg.Data[$"{pair[0]}"] = pair[1]; + } + } + client.Update(cfg); + } + } + else + { + _console.WriteWarning("No TenantId found in token; skipping tenant config map."); + } } catch (Exception e) { // print the stack trace _console.WriteLine(e.StackTrace); - _console.WriteError("Failed to update reverse proxy: " + e.Message); + _console.WriteError("Failed to update reverse proxy or tenant config: " + e.Message); throw e; } } diff --git a/cli/src/Vdk/Commands/LoginCommand.cs b/cli/src/Vdk/Commands/LoginCommand.cs new file mode 100644 index 0000000..8b95049 --- /dev/null +++ b/cli/src/Vdk/Commands/LoginCommand.cs @@ -0,0 +1,25 @@ +using System.CommandLine; +using Vdk.Services; + +namespace Vdk.Commands; + +public class LoginCommand : Command +{ + private readonly IAuthService _auth; + + public LoginCommand(IAuthService auth) : base("login", "Authenticate with the Vega identity provider using device code flow") + { + _auth = auth; + var profile = new Option("--profile") { Description = "Optional profile name for this login (supports multiple accounts)" }; + Options.Add(profile); + SetAction(async parseResult => + { + var p = parseResult.GetValue(profile); + if (!string.IsNullOrWhiteSpace(p)) + { + _auth.SetCurrentProfile(p!); + } + await _auth.LoginAsync(p); + }); + } +} diff --git a/cli/src/Vdk/Commands/LogoutCommand.cs b/cli/src/Vdk/Commands/LogoutCommand.cs new file mode 100644 index 0000000..1df56e0 --- /dev/null +++ b/cli/src/Vdk/Commands/LogoutCommand.cs @@ -0,0 +1,25 @@ +using System.CommandLine; +using Vdk.Services; + +namespace Vdk.Commands; + +public class LogoutCommand : Command +{ + private readonly IAuthService _auth; + + public LogoutCommand(IAuthService auth) : base("logout", "Remove local credentials for the current or specified profile") + { + _auth = auth; + var profile = new Option("--profile") { Description = "Optional profile name to logout (defaults to current)" }; + Options.Add(profile); + SetAction(async parseResult => + { + var p = parseResult.GetValue(profile); + if (!string.IsNullOrWhiteSpace(p)) + { + _auth.SetCurrentProfile(p!); + } + await _auth.LogoutAsync(p); + }); + } +} diff --git a/cli/src/Vdk/GlobalConfiguration.cs b/cli/src/Vdk/GlobalConfiguration.cs index b6aa543..59a0b05 100644 --- a/cli/src/Vdk/GlobalConfiguration.cs +++ b/cli/src/Vdk/GlobalConfiguration.cs @@ -1,4 +1,4 @@ -using Vdk.Constants; +using Vdk.Constants; namespace Vdk; @@ -21,4 +21,13 @@ public string VegaDirectory public string KindVersionInfoFilePath => Path.Combine(ConfigDirectoryPath, Defaults.KindVersionInfoFileName); public string MasterNodeAnnotation = "vdk.vega.io/cluster"; + + // OAuth / Hydra configuration (defaults can be overridden later) + public string HydraDeviceAuthorizationEndpoint { get; set; } = "https://idp.dev-k8s.cloud/oidc/oauth2/device/auth"; + public string HydraTokenEndpoint { get; set; } = "https://idp.dev-k8s.cloud/oidc/oauth2/token"; + public string OAuthClientId { get; set; } = "vega-cli"; + public string[] OAuthScopes { get; set; } = new[] { "openid", "offline", "profile" }; + + // JWT claim names + public string TenantIdClaim { get; set; } = "tenant_id"; } \ No newline at end of file diff --git a/cli/src/Vdk/Models/AuthTokens.cs b/cli/src/Vdk/Models/AuthTokens.cs new file mode 100644 index 0000000..0a1e896 --- /dev/null +++ b/cli/src/Vdk/Models/AuthTokens.cs @@ -0,0 +1,8 @@ +namespace Vdk.Models; + +public class AuthTokens +{ + public string AccessToken { get; set; } = string.Empty; + public string RefreshToken { get; set; } = string.Empty; + public DateTimeOffset ExpiresAt { get; set; } +} diff --git a/cli/src/Vdk/Program.cs b/cli/src/Vdk/Program.cs index 2c106f0..0973db1 100644 --- a/cli/src/Vdk/Program.cs +++ b/cli/src/Vdk/Program.cs @@ -1,6 +1,8 @@ using System.CommandLine; +using System.Linq; using Microsoft.Extensions.DependencyInjection; using Vdk.Commands; +using Vdk.Services; namespace Vdk; @@ -10,6 +12,17 @@ class Program static async Task Main(string[] args) { + var auth = Services.GetRequiredService(); + // Skip auth for explicit non-exec commands + var skipAuth = args.Length == 0 || + args.Contains("--help") || args.Contains("-h") || + args.Contains("--version") || + (args.Length > 0 && (string.Equals(args[0], "login", StringComparison.OrdinalIgnoreCase) || + string.Equals(args[0], "logout", StringComparison.OrdinalIgnoreCase))); + if (!skipAuth) + { + await auth.EnsureAuthenticatedAsync(); + } return await Services.GetRequiredService().Parse(args).InvokeAsync(); } } diff --git a/cli/src/Vdk/ServiceProviderBuilder.cs b/cli/src/Vdk/ServiceProviderBuilder.cs index 05138a7..2b7f4d2 100644 --- a/cli/src/Vdk/ServiceProviderBuilder.cs +++ b/cli/src/Vdk/ServiceProviderBuilder.cs @@ -11,6 +11,7 @@ using Octokit; using Vdk.Constants; using k8s.Exceptions; +using System.Net.Http; namespace Vdk; @@ -37,6 +38,8 @@ public static IServiceProvider Build() .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() @@ -57,6 +60,10 @@ public static IServiceProvider Build() .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(_ => new HttpClient()) + .AddSingleton() .AddSingleton(provider => { // Intelligent fallback logic diff --git a/cli/src/Vdk/Services/AuthService.cs b/cli/src/Vdk/Services/AuthService.cs new file mode 100644 index 0000000..bd12d17 --- /dev/null +++ b/cli/src/Vdk/Services/AuthService.cs @@ -0,0 +1,109 @@ +using System.Text.Json; +using System.Text; +using System.Linq; +using Vdk.Models; + +namespace Vdk.Services; + +public class AuthService : IAuthService +{ + private readonly ITokenStore _store; + private readonly HydraDeviceFlowClient _hydra; + private readonly GlobalConfiguration _config; + private readonly IConsole _console; + + public AuthService(ITokenStore store, HydraDeviceFlowClient hydra, GlobalConfiguration config, IConsole console) + { + _store = store; + _hydra = hydra; + _config = config; + _console = console; + } + + public string GetCurrentProfile() => _store.GetCurrentProfile(); + + public void SetCurrentProfile(string profile) => _store.SetCurrentProfile(profile); + + public async Task IsAuthenticatedAsync(string? profile = null, CancellationToken ct = default) + { + var tokens = await GetTokensAsync(profile, ct); + return tokens is not null; + } + + public async Task EnsureAuthenticatedAsync(string? profile = null, CancellationToken ct = default) + { + var tokens = await GetTokensAsync(profile, ct); + if (tokens is null) + { + _console.WriteLine("You are not logged in. Starting device login..."); + await LoginAsync(profile, ct); + } + } + + public async Task GetTokensAsync(string? profile = null, CancellationToken ct = default) + { + profile ??= GetCurrentProfile(); + var tokens = await _store.LoadAsync(profile, ct); + if (tokens is null) return null; + if (DateTimeOffset.UtcNow >= tokens.ExpiresAt) + { + if (string.IsNullOrWhiteSpace(tokens.RefreshToken)) return null; + try + { + tokens = await _hydra.RefreshAsync(tokens.RefreshToken, ct); + await _store.SaveAsync(profile, tokens, ct); + } + catch + { + return null; + } + } + return tokens; + } + + public async Task GetTenantIdAsync(string? profile = null, CancellationToken ct = default) + { + var tokens = await GetTokensAsync(profile, ct); + if (tokens is null) return null; + return ExtractClaim(tokens.AccessToken, _config.TenantIdClaim); + } + + public async Task LoginAsync(string? profile = null, CancellationToken ct = default) + { + profile ??= GetCurrentProfile(); + var (deviceCode, userCode, verificationUri, complete, interval) = await _hydra.BeginAsync(ct); + _console.WriteLine($"To sign in, visit: {complete}"); + _console.WriteLine($"Device code: {userCode}"); + var tokens = await _hydra.PollForTokenAsync(deviceCode, interval, ct); + await _store.SaveAsync(profile, tokens, ct); + _console.WriteLine("Login successful."); + } + + public async Task LogoutAsync(string? profile = null, CancellationToken ct = default) + { + profile ??= GetCurrentProfile(); + await _store.DeleteAsync(profile, ct); + _console.WriteLine("Logged out. Local credentials removed."); + } + + private static string? ExtractClaim(string jwt, string claimName) + { + try + { + var parts = jwt.Split('.') + .Select(p => p.Replace('-', '+').Replace('_', '/')) + .ToArray(); + if (parts.Length < 2) return null; + var padded = parts[1].PadRight(parts[1].Length + ((4 - parts[1].Length % 4) % 4), '='); + var payloadBytes = Convert.FromBase64String(padded); + var payloadJson = Encoding.UTF8.GetString(payloadBytes); + using var doc = JsonDocument.Parse(payloadJson); + if (doc.RootElement.TryGetProperty(claimName, out var el)) + { + return el.GetString(); + } + } + catch { } + return null; + } +} diff --git a/cli/src/Vdk/Services/HydraDeviceFlowClient.cs b/cli/src/Vdk/Services/HydraDeviceFlowClient.cs new file mode 100644 index 0000000..da37f06 --- /dev/null +++ b/cli/src/Vdk/Services/HydraDeviceFlowClient.cs @@ -0,0 +1,101 @@ +using System.Net.Http.Headers; +using System.Net.Http; +using System.Text.Json; +using System.Text; +using Vdk.Models; + +namespace Vdk.Services; + +public class HydraDeviceFlowClient +{ + private readonly HttpClient _http; + private readonly GlobalConfiguration _config; + + public HydraDeviceFlowClient(HttpClient http, GlobalConfiguration config) + { + _http = http; + _config = config; + } + + private record DeviceAuthResponse(string device_code, string user_code, string verification_uri, string verification_uri_complete, int expires_in, int interval); + private record TokenResponse(string access_token, string? refresh_token, int expires_in, string token_type, string? id_token); + + public async Task<(string deviceCode, string userCode, string verificationUri, string verificationUriComplete, int interval)> BeginAsync(CancellationToken ct) + { + var form = new Dictionary + { + ["client_id"] = _config.OAuthClientId, + + ["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code" + }; + using var req = new HttpRequestMessage(HttpMethod.Post, _config.HydraDeviceAuthorizationEndpoint) + { + Content = new FormUrlEncodedContent(form) + }; + using var resp = await _http.SendAsync(req, ct); + resp.EnsureSuccessStatusCode(); + var data = await JsonSerializer.DeserializeAsync(await resp.Content.ReadAsStreamAsync(ct), cancellationToken: ct) + ?? throw new InvalidOperationException("Invalid device auth response"); + return (data.device_code, data.user_code, data.verification_uri, data.verification_uri_complete, data.interval); + } + + public async Task PollForTokenAsync(string deviceCode, int intervalSeconds, CancellationToken ct) + { + while (true) + { + ct.ThrowIfCancellationRequested(); + var form = new Dictionary + { + ["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code", + ["device_code"] = deviceCode, + ["client_id"] = _config.OAuthClientId + }; + using var req = new HttpRequestMessage(HttpMethod.Post, _config.HydraTokenEndpoint) + { + Content = new FormUrlEncodedContent(form) + }; + using var resp = await _http.SendAsync(req, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + if (resp.IsSuccessStatusCode) + { + var tok = JsonSerializer.Deserialize(body) ?? throw new InvalidOperationException("Invalid token response"); + return new AuthTokens + { + AccessToken = tok.access_token, + RefreshToken = tok.refresh_token ?? string.Empty, + ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(tok.expires_in - 30) + }; + } + if (body.Contains("authorization_pending") || body.Contains("slow_down")) + { + await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), ct); + continue; + } + throw new InvalidOperationException($"Device code flow failed: {body}"); + } + } + + public async Task RefreshAsync(string refreshToken, CancellationToken ct) + { + var form = new Dictionary + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken, + ["client_id"] = _config.OAuthClientId + }; + using var req = new HttpRequestMessage(HttpMethod.Post, _config.HydraTokenEndpoint) + { + Content = new FormUrlEncodedContent(form) + }; + using var resp = await _http.SendAsync(req, ct); + resp.EnsureSuccessStatusCode(); + var tok = await JsonSerializer.DeserializeAsync(await resp.Content.ReadAsStreamAsync(ct), cancellationToken: ct) + ?? throw new InvalidOperationException("Invalid token response"); + return new AuthTokens + { + AccessToken = tok.access_token, + RefreshToken = tok.refresh_token ?? refreshToken, + ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(tok.expires_in - 30) + }; + } +} diff --git a/cli/src/Vdk/Services/IAuthService.cs b/cli/src/Vdk/Services/IAuthService.cs new file mode 100644 index 0000000..2464838 --- /dev/null +++ b/cli/src/Vdk/Services/IAuthService.cs @@ -0,0 +1,15 @@ +using Vdk.Models; + +namespace Vdk.Services; + +public interface IAuthService +{ + Task IsAuthenticatedAsync(string? profile = null, CancellationToken ct = default); + Task EnsureAuthenticatedAsync(string? profile = null, CancellationToken ct = default); + Task GetTokensAsync(string? profile = null, CancellationToken ct = default); + Task GetTenantIdAsync(string? profile = null, CancellationToken ct = default); + Task LoginAsync(string? profile = null, CancellationToken ct = default); + Task LogoutAsync(string? profile = null, CancellationToken ct = default); + string GetCurrentProfile(); + void SetCurrentProfile(string profile); +} diff --git a/cli/src/Vdk/Services/ITokenStore.cs b/cli/src/Vdk/Services/ITokenStore.cs new file mode 100644 index 0000000..e05700d --- /dev/null +++ b/cli/src/Vdk/Services/ITokenStore.cs @@ -0,0 +1,13 @@ +using Vdk.Models; + +namespace Vdk.Services; + +public interface ITokenStore +{ + Task LoadAsync(string profile, CancellationToken ct = default); + Task SaveAsync(string profile, AuthTokens tokens, CancellationToken ct = default); + Task DeleteAsync(string profile, CancellationToken ct = default); + string GetCurrentProfile(); + void SetCurrentProfile(string profile); + IEnumerable ListProfiles(); +} diff --git a/cli/src/Vdk/Services/TokenStoreFile.cs b/cli/src/Vdk/Services/TokenStoreFile.cs new file mode 100644 index 0000000..24ca008 --- /dev/null +++ b/cli/src/Vdk/Services/TokenStoreFile.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using Vdk.Models; + +namespace Vdk.Services; + +public class TokenStoreFile : ITokenStore +{ + private readonly GlobalConfiguration _config; + + private record Persisted(AuthTokens Tokens); + + public TokenStoreFile(GlobalConfiguration config) + { + _config = config; + Directory.CreateDirectory(TokensDirectory); + } + + private string TokensDirectory => Path.Combine(_config.VegaDirectory, "tokens"); + private string ProfilesFile => Path.Combine(TokensDirectory, ".current_profile"); + private string ProfilePath(string profile) => Path.Combine(TokensDirectory, $"{profile}.json"); + + public async Task LoadAsync(string profile, CancellationToken ct = default) + { + var path = ProfilePath(profile); + if (!File.Exists(path)) return null; + await using var fs = File.OpenRead(path); + var persisted = await JsonSerializer.DeserializeAsync(fs, cancellationToken: ct); + return persisted?.Tokens; + } + + public async Task SaveAsync(string profile, AuthTokens tokens, CancellationToken ct = default) + { + Directory.CreateDirectory(TokensDirectory); + var path = ProfilePath(profile); + await using var fs = File.Create(path); + await JsonSerializer.SerializeAsync(fs, new Persisted(tokens), options: new JsonSerializerOptions { WriteIndented = true }, cancellationToken: ct); + } + + public Task DeleteAsync(string profile, CancellationToken ct = default) + { + var path = ProfilePath(profile); + if (File.Exists(path)) File.Delete(path); + if (File.Exists(ProfilesFile)) File.Delete(ProfilesFile); + return Task.CompletedTask; + } + + public string GetCurrentProfile() + { + if (File.Exists(ProfilesFile)) + { + var p = File.ReadAllText(ProfilesFile).Trim(); + if (!string.IsNullOrWhiteSpace(p)) return p; + } + return "default"; + } + + public void SetCurrentProfile(string profile) + { + Directory.CreateDirectory(TokensDirectory); + File.WriteAllText(ProfilesFile, profile); + } + + public IEnumerable ListProfiles() + { + if (!Directory.Exists(TokensDirectory)) yield break; + foreach (var file in Directory.EnumerateFiles(TokensDirectory, "*.json")) + { + yield return Path.GetFileNameWithoutExtension(file); + } + } +} diff --git a/cli/src/Vdk/vega.conf b/cli/src/Vdk/vega.conf index e69de29..3277871 100644 --- a/cli/src/Vdk/vega.conf +++ b/cli/src/Vdk/vega.conf @@ -0,0 +1,42 @@ + + + + +##### START vdk +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name vdk.dev-k8s.cloud; + ssl_certificate /etc/certs/fullchain.pem; + ssl_certificate_key /etc/certs/privkey.pem; + location / { + proxy_pass https://host.docker.internal:55201; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +##### END vdk + + + +##### START vdk-1 +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name vdk-1.dev-k8s.cloud; + ssl_certificate /etc/certs/fullchain.pem; + ssl_certificate_key /etc/certs/privkey.pem; + location / { + proxy_pass https://host.docker.internal:51632; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +##### END vdk-1 + diff --git a/docs/installation/getting-started.md b/docs/installation/getting-started.md index c64df1d..d350021 100644 --- a/docs/installation/getting-started.md +++ b/docs/installation/getting-started.md @@ -65,7 +65,13 @@ For contributing to VDK development or running it from source, we use [Devbox](h Once VDK is installed (either via binary or built from source), verify it: ```bash -vdk --version +vega --version +``` + +Then authenticate once using the device code flow (required before most commands): + +```bash +vega login ``` ## Next Steps diff --git a/docs/usage/command-reference.md b/docs/usage/command-reference.md index 8c62b0c..0e25776 100644 --- a/docs/usage/command-reference.md +++ b/docs/usage/command-reference.md @@ -1,48 +1,62 @@ -# VDK Command Reference +# Vega Command Reference -This is a comprehensive reference for all VDK commands. +This is a comprehensive reference for all Vega commands. -## `vdk create cluster` +## `vega login` + +Authenticate using the OAuth2 Device Code flow. Supports multiple profiles. + +**Usage:** +`vega login [--profile ]` + +## `vega logout` + +Remove local credentials for the current or specified profile. + +**Usage:** +`vega logout [--profile ]` + +## `vega create cluster` Creates a new KinD cluster. **Usage:** -`vdk create cluster [flags]` +`vega create cluster [flags]` **Flags:** -* `--name string`: Name for the cluster (default: `kind`) -* `--version string`: Kubernetes version to use (e.g., `v1.25.3`). If not specified, uses KinD's default. -* `--config string`: Path to a KinD configuration file. -* `--nodes int`: Total number of nodes (control-plane + worker). -* `--control-planes int`: Number of control-plane nodes. -* `--wait duration`: Wait time for the control plane to be ready (default: `5m`). +* `--Name, -n string`: Name for the cluster (default: `vega` with auto-increment if taken) +* `--KubeVersion, -k string`: Kubernetes version (CLI resolves compatible image for KinD version) +* `--ControlPlaneNodes, -c int`: Number of control-plane nodes (default configured in CLI) +* `--Workers, -w int`: Number of worker nodes (default configured in CLI) *(Add other commands like `get`, `delete`, `version`, etc., as they are developed)* -## `vdk get clusters` +## `vega list clusters` -Lists existing KinD clusters managed by VDK. +Lists existing KinD clusters managed by Vega. -## `vdk delete cluster` +## `vega remove cluster` Deletes a KinD cluster. **Flags:** -* `--name string`: Name of the cluster to delete (default: `kind`) +* `--Name, -n string`: Name of the cluster to delete + +## `vega list kubernetes-versions` -## `vdk get kubeconfig` +Lists supported Kubernetes versions for your installed KinD version. + +## `vega get kubeconfig` Gets the kubeconfig path for a cluster. **Flags:** -* `--name string`: Name of the cluster (default: `kind`) +* `--Name, -n string`: Name of the cluster (default: `vega`) -# `vdk create cloud-provider-kind` +# `vega create cloud-provider-kind` Creates a cloud Provider KIND docker image which runs as a standalone binary in the local machine and will connect to the Kind cluster and provision new Load balancer containers for the services. - - diff --git a/docs/usage/creating-clusters.md b/docs/usage/creating-clusters.md index 8da40e4..5770b35 100644 --- a/docs/usage/creating-clusters.md +++ b/docs/usage/creating-clusters.md @@ -2,24 +2,26 @@ This document explains how to create local Kubernetes clusters using VDK. +> Prerequisite: Run `vega login` once to authenticate before creating clusters. + ## Basic Cluster Creation To create a default cluster: ```bash -vdk create cluster +vega create cluster ``` ## Specifying Cluster Name ```bash -vdk create cluster --name my-dev-cluster +vega create cluster --Name my-dev-cluster ``` ## Specifying Kubernetes Version ```bash -vdk create cluster --version v1.25.3 +vega create cluster --KubeVersion 1.29 ``` ## Multi-Node Clusters @@ -27,8 +29,8 @@ vdk create cluster --version v1.25.3 *(Details on creating clusters with multiple control-plane and worker nodes)* ```bash -# Example (syntax TBD) -vdk create cluster --nodes 3 --control-planes 1 +# Example +vega create cluster --ControlPlaneNodes 1 --Workers 2 ``` ## Using Configuration Files @@ -36,5 +38,6 @@ vdk create cluster --nodes 3 --control-planes 1 *(Details on using a KinD configuration file)* ```bash -vdk create cluster --config path/to/kind-config.yaml +# If/when a config file flag is added, document here +``` ``` diff --git a/docs/usage/managing-clusters.md b/docs/usage/managing-clusters.md index 77bc9e1..3cb3bfa 100644 --- a/docs/usage/managing-clusters.md +++ b/docs/usage/managing-clusters.md @@ -5,7 +5,7 @@ Learn how to manage your VDK-created KinD clusters. ## Listing Clusters ```bash -vdk get clusters +vega list clusters ``` Expected Output: @@ -18,12 +18,12 @@ my-dev-cluster Running v1.25.3 ## Deleting Clusters ```bash -vdk delete cluster --name my-dev-cluster +vega remove cluster --Name my-dev-cluster ``` To delete the default 'kind' cluster: ```bash -vdk delete cluster +vega remove cluster --Name vega ``` ## Getting Kubeconfig @@ -31,10 +31,10 @@ vdk delete cluster VDK typically configures `kubectl` automatically. To get the path to the kubeconfig file for a specific cluster: ```bash -vdk get kubeconfig --name my-dev-cluster +vega get kubeconfig --Name my-dev-cluster ``` Or for the default cluster: ```bash -vdk get kubeconfig +vega get kubeconfig --Name vega ```