├── .github ├── dependabot.yml └── workflows │ ├── publish-docker.yaml │ └── test-build.yaml ├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── HomepageSC.Tests ├── BuilderTests.cs ├── HomepageSC.Tests.csproj └── Usings.cs ├── HomepageSC.sln ├── HomepageSC ├── .dockerignore ├── AnnotationKey.cs ├── ConfigBuilder.cs ├── Controller.cs ├── DictExtensions.cs ├── Dockerfile ├── HomepageSC.csproj ├── Program.cs ├── Service.cs ├── SidecarOptions.cs └── Widget.cs ├── LICENCE.md └── README.md /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "nuget" # See documentation for possible values 9 | directory: "/HomepageSC" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/publish-docker.yaml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # GitHub recommends pinning actions to a commit SHA. 7 | # To get a newer version, you will need to update the SHA. 8 | # You can also reference a tag or branch, but the action may change without warning. 9 | 10 | name: Create and publish a Docker image 11 | 12 | on: 13 | push: 14 | branches: 15 | - main 16 | env: 17 | REGISTRY: ghcr.io 18 | IMAGE_NAME: ${{ github.repository }} 19 | 20 | jobs: 21 | build-and-push-image: 22 | runs-on: ubuntu-latest 23 | permissions: 24 | contents: read 25 | packages: write 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v3 30 | 31 | - name: Log in to the Container registry 32 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 33 | with: 34 | registry: ${{ env.REGISTRY }} 35 | username: ${{ github.actor }} 36 | password: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | 39 | - name: Bump version and push tag 40 | id: bump 41 | uses: anothrNick/github-tag-action@v1 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | WITH_V: true 45 | 46 | - name: Extract metadata (tags, labels) for Docker 47 | id: meta 48 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 49 | with: 50 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 51 | tags: | 52 | type=schedule 53 | type=ref,event=branch 54 | type=ref,event=pr 55 | type=semver,pattern=v{{version}} 56 | type=semver,pattern=v{{major}}.{{minor}} 57 | type=semver,pattern=v{{major}} 58 | type=sha 59 | type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} 60 | 61 | - name: Build and push Docker image 62 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 63 | with: 64 | context: . 65 | file: ./HomepageSC/Dockerfile 66 | push: ${{ github.event_name != 'pull_request' }} 67 | tags: ${{ steps.meta.outputs.tags }} 68 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.github/workflows/test-build.yaml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # GitHub recommends pinning actions to a commit SHA. 7 | # To get a newer version, you will need to update the SHA. 8 | # You can also reference a tag or branch, but the action may change without warning. 9 | 10 | name: Create and publish a Docker image 11 | 12 | on: 13 | push: 14 | branches: 15 | - "!main" 16 | env: 17 | REGISTRY: ghcr.io 18 | IMAGE_NAME: ${{ github.repository }} 19 | 20 | jobs: 21 | build-and-push-image: 22 | runs-on: ubuntu-latest 23 | permissions: 24 | contents: read 25 | packages: write 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v3 30 | 31 | - name: Build and push Docker image 32 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 33 | with: 34 | context: . 35 | file: ./HomepageSC/Dockerfile 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | obj 3 | bin -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": ".NET Core Launch (console)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | "program": "${workspaceFolder}/HomepageSC/bin/Debug/net6.0/HomepageSC.exe", 13 | "args": [], 14 | "cwd": "${workspaceFolder}", 15 | "console": "internalConsole", 16 | "stopAtEntry": false 17 | }, 18 | { 19 | "name": ".NET Core Attach", 20 | "type": "coreclr", 21 | "request": "attach" 22 | } 23 | ] 24 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "build", 8 | "command": "dotnet", 9 | "type": "shell", 10 | "args": [ 11 | "build", 12 | // Ask dotnet build to generate full paths for file names. 13 | "/property:GenerateFullPaths=true", 14 | // Do not generate summary otherwise it leads to duplicate errors in Problems panel 15 | "/consoleloggerparameters:NoSummary" 16 | ], 17 | "group": "build", 18 | "presentation": { 19 | "reveal": "silent" 20 | }, 21 | "problemMatcher": "$msCompile" 22 | } 23 | ] 24 | } -------------------------------------------------------------------------------- /HomepageSC.Tests/BuilderTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using k8s; 3 | using k8s.Models; 4 | using Microsoft.Extensions.Options; 5 | using Moq; 6 | 7 | namespace HomepageSC.Tests.Service 8 | { 9 | public class BuilderTests 10 | { 11 | private V1IngressList ingressData; 12 | 13 | [SetUp] 14 | public void BeforeEach() 15 | { 16 | ingressData = new V1IngressList 17 | { 18 | Items = new List 19 | { 20 | new() 21 | { 22 | Metadata = new V1ObjectMeta 23 | { 24 | Name = "my-ingress", 25 | NamespaceProperty = "my-namespace", 26 | Annotations = new Dictionary() 27 | }, 28 | Spec = new V1IngressSpec 29 | { 30 | Rules = new List 31 | { 32 | new() 33 | { 34 | Host = "my-host.com", 35 | Http = new V1HTTPIngressRuleValue(new List 36 | { 37 | new( 38 | new V1IngressBackend(service: new V1IngressServiceBackend("my-service", 39 | new V1ServiceBackendPort(number: 80))), "Prefix", "/my-path") 40 | }) 41 | } 42 | } 43 | } 44 | } 45 | } 46 | }; 47 | } 48 | 49 | [Test] 50 | public async Task BuildsNameAndPath() 51 | { 52 | var kubeClient = new Mock(MockBehavior.Default); 53 | var configBuilder = 54 | new ConfigBuilder(kubeClient.Object, new OptionsWrapper(new SidecarOptions())); 55 | var config = await configBuilder.Build(ingressData, CancellationToken.None); 56 | config["Default"]["my-ingress"].Href.Should() 57 | .Be("http://my-host.com/my-path", "Should construct the the full path"); 58 | } 59 | 60 | [Test] 61 | public async Task ConfigureGroup() 62 | { 63 | var kubeClient = new Mock(MockBehavior.Default); 64 | var configBuilder = 65 | new ConfigBuilder(kubeClient.Object, new OptionsWrapper(new SidecarOptions())); 66 | ingressData.Items.Single().Metadata.Annotations[AnnotationKey.Group] = "Some Other Group"; 67 | var config = await configBuilder.Build(ingressData, CancellationToken.None); 68 | config["Some Other Group"]["my-ingress"].Href.Should() 69 | .Be("http://my-host.com/my-path", "Should construct the the full path"); 70 | } 71 | 72 | [Test] 73 | public async Task ConfigureName() 74 | { 75 | var kubeClient = new Mock(MockBehavior.Default); 76 | var configBuilder = 77 | new ConfigBuilder(kubeClient.Object, new OptionsWrapper(new SidecarOptions())); 78 | ingressData.Items.Single().Metadata.Annotations[AnnotationKey.AppName] = "Some Different Name"; 79 | var config = await configBuilder.Build(ingressData, CancellationToken.None); 80 | config["Default"]["Some Different Name"].Href.Should() 81 | .Be("http://my-host.com/my-path", "Should construct the the full path"); 82 | } 83 | 84 | [Test] 85 | public async Task ConfigureIcon() 86 | { 87 | var kubeClient = new Mock(MockBehavior.Default); 88 | var configBuilder = 89 | new ConfigBuilder(kubeClient.Object, new OptionsWrapper(new SidecarOptions())); 90 | ingressData.Items.Single().Metadata.Annotations[AnnotationKey.Icon] = "http://awesomeicons.local/some-icon.png"; 91 | var config = await configBuilder.Build(ingressData, CancellationToken.None); 92 | config["Default"]["my-ingress"].Icon.Should() 93 | .Be("http://awesomeicons.local/some-icon.png", "Should populate icon from annotation"); 94 | } 95 | 96 | [Test] 97 | public async Task ConfigureDescription() 98 | { 99 | var kubeClient = new Mock(MockBehavior.Default); 100 | var configBuilder = 101 | new ConfigBuilder(kubeClient.Object, new OptionsWrapper(new SidecarOptions())); 102 | ingressData.Items.Single().Metadata.Annotations[AnnotationKey.Description] = "An awesome and interesting description"; 103 | var config = await configBuilder.Build(ingressData, CancellationToken.None); 104 | config["Default"]["my-ingress"].Description.Should() 105 | .Be("An awesome and interesting description", "Should populate icon from annotation"); 106 | } 107 | 108 | [Test] 109 | public async Task ConfigureHealthcheck() 110 | { 111 | var kubeClient = new Mock(MockBehavior.Default); 112 | var configBuilder = 113 | new ConfigBuilder(kubeClient.Object, new OptionsWrapper(new SidecarOptions())); 114 | ingressData.Items.Single().Metadata.Annotations[AnnotationKey.Healthcheck] = "http://service.namespace.svc.cluster.local"; 115 | var config = await configBuilder.Build(ingressData, CancellationToken.None); 116 | config["Default"]["my-ingress"].Ping.Should() 117 | .Be("http://service.namespace.svc.cluster.local", "Should populate ping from healthcheck annotation"); 118 | } 119 | 120 | [Test] 121 | public async Task HttpsIngress() 122 | { 123 | var kubeClient = new Mock(MockBehavior.Default); 124 | var configBuilder = 125 | new ConfigBuilder(kubeClient.Object, new OptionsWrapper(new SidecarOptions())); 126 | string secureHost = "my-secure-host.com"; 127 | ingressData.Items.Single().Spec.Tls = new List 128 | { 129 | new (new List { secureHost }) 130 | }; 131 | ingressData.Items.Single().Spec.Rules.Single().Host = secureHost; 132 | var config = await configBuilder.Build(ingressData, CancellationToken.None); 133 | config["Default"]["my-ingress"].Href.Should() 134 | .Be($"https://{secureHost}/my-path", "Should construct an https path"); 135 | } 136 | 137 | [Test] 138 | public async Task MultiplePaths() 139 | { 140 | var kubeClient = new Mock(MockBehavior.Default); 141 | var configBuilder = 142 | new ConfigBuilder(kubeClient.Object, new OptionsWrapper(new SidecarOptions())); 143 | ingressData.Items = new List 144 | { 145 | new V1Ingress { 146 | Metadata = new V1ObjectMeta 147 | { 148 | Name = "some-ingress", 149 | Annotations = new Dictionary() 150 | }, 151 | Spec = new V1IngressSpec { 152 | Rules = new List { 153 | new V1IngressRule { 154 | Host = "some-host.com", 155 | Http = new V1HTTPIngressRuleValue { 156 | Paths = new List { 157 | new V1HTTPIngressPath { 158 | Backend = new V1IngressBackend { 159 | Service = new V1IngressServiceBackend("my-service", new V1ServiceBackendPort(number: 80)) 160 | }, 161 | Path = "/" 162 | }, 163 | new V1HTTPIngressPath { 164 | Backend = new V1IngressBackend { 165 | Service = new V1IngressServiceBackend("my-service", new V1ServiceBackendPort(number: 80)) 166 | }, 167 | Path = "/sub-path" 168 | } 169 | } 170 | } 171 | } 172 | } 173 | } 174 | } 175 | }; 176 | 177 | var config = await configBuilder.Build(ingressData, CancellationToken.None); 178 | config["Default"]["some-ingress"].Href.Should() 179 | .Be($"http://some-host.com/", "Should create a service for the first path"); 180 | 181 | config["Default"]["some-ingress-1"].Href.Should() 182 | .Be($"http://some-host.com/sub-path", "Should create a service for the second path"); 183 | } 184 | 185 | [Test] 186 | public async Task TargetEmptyByDefault() 187 | { 188 | var kubeClient = new Mock(MockBehavior.Default); 189 | var configBuilder = 190 | new ConfigBuilder(kubeClient.Object, new OptionsWrapper(new SidecarOptions())); 191 | var config = await configBuilder.Build(ingressData, CancellationToken.None); 192 | config["Default"]["my-ingress"].Target.Should().BeNull(); 193 | } 194 | 195 | [Test] 196 | public async Task TargetConfiguredBySetting() 197 | { 198 | var kubeClient = new Mock(MockBehavior.Default); 199 | var configBuilder = 200 | new ConfigBuilder(kubeClient.Object, new OptionsWrapper(new SidecarOptions { DefaultTarget = Target._top})); 201 | var config = await configBuilder.Build(ingressData, CancellationToken.None); 202 | config["Default"]["my-ingress"].Target.Should().Be("_top"); 203 | } 204 | 205 | [Test] 206 | public async Task TargetOverridenByAnnotation() 207 | { 208 | var kubeClient = new Mock(MockBehavior.Default); 209 | var configBuilder = 210 | new ConfigBuilder(kubeClient.Object, new OptionsWrapper(new SidecarOptions { DefaultTarget = Target._top})); 211 | ingressData.Items.Single().Metadata.Annotations[AnnotationKey.Target] = "_self"; 212 | var config = await configBuilder.Build(ingressData, CancellationToken.None); 213 | config["Default"]["my-ingress"].Target.Should().Be("_self"); 214 | } 215 | 216 | [Test] 217 | public async Task DisabledBySettingOverriddenByAnnotation() 218 | { 219 | var kubeClient = new Mock(MockBehavior.Default); 220 | var configBuilder = 221 | new ConfigBuilder(kubeClient.Object, new OptionsWrapper(new SidecarOptions{IncludeByDefault = false})); 222 | ingressData.Items.Single().Metadata.Annotations[AnnotationKey.Enable] = "true"; 223 | var config = await configBuilder.Build(ingressData, CancellationToken.None); 224 | config["Default"]["my-ingress"].Href.Should().NotBeEmpty(); 225 | } 226 | 227 | [Test] 228 | public async Task DisabledBySetting() 229 | { 230 | var kubeClient = new Mock(MockBehavior.Default); 231 | var configBuilder = 232 | new ConfigBuilder(kubeClient.Object, new OptionsWrapper(new SidecarOptions{IncludeByDefault = false})); 233 | var config = await configBuilder.Build(ingressData, CancellationToken.None); 234 | config.Should().BeEmpty(); 235 | } 236 | } 237 | } 238 | 239 | // WIDGETS 240 | // enable widget 241 | // populate widget key from secret with/without 242 | 243 | // OUTPUT 244 | // path configuration 245 | 246 | // CONFIGURATION 247 | // incluster flag on and off 248 | -------------------------------------------------------------------------------- /HomepageSC.Tests/HomepageSC.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | false 9 | 10 | HomepageSC.Tests 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /HomepageSC.Tests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using NUnit.Framework; -------------------------------------------------------------------------------- /HomepageSC.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HomepageSC", "HomepageSC\HomepageSC.csproj", "{A0306088-E4D0-4545-999B-BAEEF7F1BE40}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HomepageSC.Tests", "HomepageSC.Tests\HomepageSC.Tests.csproj", "{81CC655A-6D1C-4F6A-AFA7-192FF3249B26}" 6 | EndProject 7 | Global 8 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 9 | Debug|Any CPU = Debug|Any CPU 10 | Release|Any CPU = Release|Any CPU 11 | EndGlobalSection 12 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 13 | {A0306088-E4D0-4545-999B-BAEEF7F1BE40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 14 | {A0306088-E4D0-4545-999B-BAEEF7F1BE40}.Debug|Any CPU.Build.0 = Debug|Any CPU 15 | {A0306088-E4D0-4545-999B-BAEEF7F1BE40}.Release|Any CPU.ActiveCfg = Release|Any CPU 16 | {A0306088-E4D0-4545-999B-BAEEF7F1BE40}.Release|Any CPU.Build.0 = Release|Any CPU 17 | {81CC655A-6D1C-4F6A-AFA7-192FF3249B26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {81CC655A-6D1C-4F6A-AFA7-192FF3249B26}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {81CC655A-6D1C-4F6A-AFA7-192FF3249B26}.Release|Any CPU.ActiveCfg = Release|Any CPU 20 | {81CC655A-6D1C-4F6A-AFA7-192FF3249B26}.Release|Any CPU.Build.0 = Release|Any CPU 21 | EndGlobalSection 22 | EndGlobal 23 | -------------------------------------------------------------------------------- /HomepageSC/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.dockerignore 2 | **/.env 3 | **/.git 4 | **/.gitignore 5 | **/.project 6 | **/.settings 7 | **/.toolstarget 8 | **/.vs 9 | **/.vscode 10 | **/.idea 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md -------------------------------------------------------------------------------- /HomepageSC/AnnotationKey.cs: -------------------------------------------------------------------------------- 1 | namespace HomepageSC; 2 | 3 | public static class AnnotationKey 4 | { 5 | public const string Enable = "homepagesc.neutrino.io/enable"; 6 | public const string Group = "homepagesc.neutrino.io/group"; 7 | public const string WidgetType = "homepagesc.neutrino.io/widget_type"; 8 | public const string WidgetSecret = "homepagesc.neutrino.io/widget_secret"; 9 | public const string Target = "homepagesc.neutrino.io/target"; 10 | public const string Description = "homepagesc.neutrino.io/description"; 11 | public const string Icon = "homepagesc.neutrino.io/icon"; 12 | public const string Healthcheck = "homepagesc.neutrino.io/healthCheck"; 13 | public const string AppName = "homepagesc.neutrino.io/appName"; 14 | } -------------------------------------------------------------------------------- /HomepageSC/ConfigBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using k8s; 3 | using k8s.Models; 4 | using Microsoft.Extensions.Options; 5 | 6 | namespace HomepageSC; 7 | 8 | public class ConfigBuilder 9 | { 10 | private readonly IKubernetes _kubeClientObject; 11 | private readonly SidecarOptions _options; 12 | 13 | public ConfigBuilder(IKubernetes kubeClientObject, IOptions sidecarOptions) 14 | { 15 | _kubeClientObject = kubeClientObject; 16 | _options = sidecarOptions.Value; 17 | } 18 | 19 | private string? Get(V1Ingress ingress, string attributeName) 20 | { 21 | return ingress.Metadata.Annotations.ContainsKey(attributeName) 22 | ? ingress.Metadata.Annotations[attributeName] 23 | : null; 24 | } 25 | 26 | public async Task>> Build(V1IngressList ingressData, 27 | CancellationToken token) 28 | { 29 | Dictionary> flatConfig = new(); 30 | foreach (var ingress in ingressData.Items) 31 | { 32 | if ("false".Equals(Get(ingress, AnnotationKey.Enable) ?? _options.IncludeByDefault.ToString(), 33 | StringComparison.InvariantCultureIgnoreCase)) continue; 34 | 35 | var groupName = ingress.Metadata.Annotations.ContainsKey(AnnotationKey.Group) 36 | ? ingress.Metadata.Annotations[AnnotationKey.Group] 37 | : "Default"; 38 | 39 | foreach (var rule in ingress.Spec.Rules) 40 | if (rule.Http != null) 41 | { 42 | int pathNumber = 0; 43 | foreach (var path in rule.Http.Paths) 44 | { 45 | string scheme = 46 | ingress.Spec.Tls != null && ingress.Spec.Tls.Any(tls => tls.Hosts.Contains(rule.Host)) 47 | ? "https" 48 | : "http"; 49 | var url = $"{scheme}://{rule.Host}{path.Path}"; 50 | var widgetType = Get(ingress, AnnotationKey.WidgetType); 51 | 52 | Widget? widget = null; 53 | if (!string.IsNullOrEmpty(widgetType)) 54 | { 55 | string? apiKey = null; 56 | var apiKeySecretName = Get(ingress, AnnotationKey.WidgetSecret); 57 | if (!string.IsNullOrEmpty(apiKeySecretName)) 58 | { 59 | var secretParts = apiKeySecretName.Split('/'); 60 | // TODO: improved formatting 61 | var secret = await _kubeClientObject.CoreV1.ReadNamespacedSecretAsync(secretParts[1], 62 | secretParts[0], cancellationToken: token); 63 | apiKey = Encoding.Default.GetString(secret.Data[secretParts[2]]); 64 | } 65 | 66 | var port = path.Backend.Service.Port.Number; 67 | 68 | if (port == null) 69 | { 70 | var service = await _kubeClientObject.CoreV1.ReadNamespacedServiceAsync( 71 | path.Backend.Service.Name, 72 | ingress.Metadata.NamespaceProperty, cancellationToken: token); 73 | port = service.Spec.Ports.Single(p => p.Name == path.Backend.Service.Port.Name) 74 | .Port; 75 | } 76 | 77 | widget = new Widget(widgetType, 78 | $"http://{path.Backend.Service.Name}.{ingress.Metadata.NamespaceProperty}.svc.cluster.local:{port}", 79 | apiKey); 80 | } 81 | 82 | var target = Get(ingress, AnnotationKey.Target); 83 | 84 | var newValue = new Service(url) 85 | { 86 | Description = Get(ingress, AnnotationKey.Description), 87 | Icon = Get(ingress, AnnotationKey.Icon), 88 | Ping = Get(ingress, AnnotationKey.Healthcheck), 89 | Target = target ?? (_options.DefaultTarget != Target.Default ? _options.DefaultTarget.ToString() : null), 90 | Widget = widget 91 | }; 92 | var serviceName = Get(ingress, AnnotationKey.AppName) ?? ingress.Metadata.Name; 93 | 94 | if (pathNumber > 0 ) 95 | { 96 | serviceName += "-" + pathNumber; 97 | } 98 | 99 | pathNumber++; 100 | var group = flatConfig.GetOrAdd(groupName, new Dictionary()); 101 | group[serviceName] = newValue; 102 | } 103 | } 104 | } 105 | 106 | return flatConfig; 107 | } 108 | } -------------------------------------------------------------------------------- /HomepageSC/Controller.cs: -------------------------------------------------------------------------------- 1 | using k8s; 2 | using Microsoft.Extensions.Hosting; 3 | using Microsoft.Extensions.Options; 4 | using YamlDotNet.Serialization; 5 | using YamlDotNet.Serialization.NamingConventions; 6 | 7 | namespace HomepageSC; 8 | 9 | public class Controller : BackgroundService 10 | { 11 | private readonly IKubernetes _client; 12 | private readonly ConfigBuilder _configBuilder; 13 | private readonly SidecarOptions _options; 14 | 15 | public Controller(ConfigBuilder configBuilder, IKubernetes client, IOptions options) 16 | { 17 | _configBuilder = configBuilder; 18 | _client = client; 19 | _options = options.Value; 20 | } 21 | 22 | protected override async Task ExecuteAsync(CancellationToken token) 23 | { 24 | do 25 | { 26 | var result1 = await _client.NetworkingV1.ListIngressForAllNamespacesAsync(cancellationToken: token); 27 | var flatConfig = await _configBuilder.Build(result1, token); 28 | var c = flatConfig.Select(g => new Dictionary>> 29 | { { g.Key, g.Value.Select(s => new Dictionary { { s.Key, s.Value } }).ToList() } }) 30 | .ToList(); 31 | 32 | 33 | var serializer = new SerializerBuilder() 34 | .WithNamingConvention(CamelCaseNamingConvention.Instance) 35 | .Build(); 36 | var configOutput = serializer.Serialize(c); 37 | 38 | if (!string.IsNullOrEmpty(_options.OutputLocation)) 39 | { 40 | Console.WriteLine($"Writing {flatConfig.Values.SelectMany(c => c.Values).Count()} services to: {_options.OutputLocation}"); 41 | await File.WriteAllTextAsync(_options.OutputLocation, configOutput, token); 42 | } 43 | else 44 | { 45 | Console.WriteLine(configOutput); 46 | } 47 | 48 | await Task.Delay(10000, token); 49 | } while (!token.IsCancellationRequested); 50 | } 51 | } -------------------------------------------------------------------------------- /HomepageSC/DictExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace HomepageSC; 2 | 3 | public static class DictExtensions 4 | { 5 | public static TValue GetOrAdd(this Dictionary self, TKey key, TValue defaultValue) 6 | where TKey : notnull 7 | { 8 | if (self.ContainsKey(key)) return self[key]; 9 | return self[key] = defaultValue; 10 | } 11 | } -------------------------------------------------------------------------------- /HomepageSC/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/runtime:6.0 AS base 2 | WORKDIR /app 3 | 4 | FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build 5 | WORKDIR /src 6 | COPY ["HomepageSC/HomepageSC.csproj", "HomepageSC/"] 7 | RUN dotnet restore "HomepageSC/HomepageSC.csproj" 8 | COPY . . 9 | RUN dotnet test 10 | WORKDIR "/src/HomepageSC" 11 | RUN dotnet build "HomepageSC.csproj" -c Release -o /app/build 12 | 13 | FROM build AS publish 14 | RUN dotnet publish "HomepageSC.csproj" -c Release -o /app/publish 15 | 16 | FROM base AS final 17 | WORKDIR /app 18 | COPY --from=publish /app/publish . 19 | ENTRYPOINT ["dotnet", "HomepageSC.dll"] 20 | -------------------------------------------------------------------------------- /HomepageSC/HomepageSC.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net6.0 6 | enable 7 | enable 8 | Linux 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /HomepageSC/Program.cs: -------------------------------------------------------------------------------- 1 | using HomepageSC; 2 | using k8s; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Hosting; 5 | using Microsoft.Extensions.Options; 6 | 7 | Host.CreateDefaultBuilder(args) 8 | .ConfigureServices((hostContext, services) => 9 | { 10 | services.AddSingleton(); 11 | services.AddHostedService(); 12 | services.AddSingleton(s => 13 | { 14 | var options = s.GetService>()!; 15 | if (options.Value.InCluster) 16 | return new Kubernetes(KubernetesClientConfiguration.InClusterConfig()); 17 | return new Kubernetes(KubernetesClientConfiguration.BuildConfigFromConfigFile()); 18 | }); 19 | services.Configure( 20 | hostContext.Configuration); 21 | }) 22 | .Build() 23 | .Run(); -------------------------------------------------------------------------------- /HomepageSC/Service.cs: -------------------------------------------------------------------------------- 1 | namespace HomepageSC; 2 | 3 | public class Service 4 | { 5 | public Service(string href) 6 | { 7 | Href = href; 8 | } 9 | 10 | public string? Icon { get; set; } 11 | public string Href { get; set; } 12 | public string? Description { get; set; } 13 | public string? Ping { get; set; } 14 | public string? Container { get; set; } 15 | public Widget? Widget { get; set; } 16 | public string? Target { get; set; } 17 | } -------------------------------------------------------------------------------- /HomepageSC/SidecarOptions.cs: -------------------------------------------------------------------------------- 1 | namespace HomepageSC; 2 | 3 | public class SidecarOptions 4 | { 5 | public bool InCluster { get; set; } 6 | public bool IncludeByDefault { get; set; } = true; 7 | public string? OutputLocation { get; set; } 8 | public Target DefaultTarget { get; set; } 9 | } 10 | 11 | public enum Target 12 | { 13 | Default, 14 | _blank, 15 | _self, 16 | _top 17 | } -------------------------------------------------------------------------------- /HomepageSC/Widget.cs: -------------------------------------------------------------------------------- 1 | namespace HomepageSC; 2 | 3 | public class Widget 4 | { 5 | public Widget(string type, string url, string? key) 6 | { 7 | Type = type; 8 | Url = url; 9 | Key = key; 10 | } 11 | 12 | public string Type { get; } 13 | public string Url { get; } 14 | public string? Key { get; } 15 | } -------------------------------------------------------------------------------- /LICENCE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Robert Stiff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HomepageSC 2 | 3 | HomepageSC provides Kubernetes Ingress support to [Homepage by Ben Phelps](https://github.com/benphelps/homepage). 4 | 5 | Running as a side car container to your HomePage container, this side car enumerates all Ingresses in your Kubernetes cluster, building a [service.yaml](https://gethomepage.dev/en/configs/services/) fully populated with all your endpoints. 6 | 7 | ## Usage 8 | 9 | Deploy Homepage and HomepageSC in a single deployment with a shared volume for them to exchange their configuration. 10 | 11 | ```yaml 12 | ## Deploy Homepage and HomepageSC with a shared config volume 13 | apiVersion: apps/v1 14 | kind: Deployment 15 | metadata: 16 | name: homepage 17 | namespace: homepage 18 | labels: 19 | app: homepage 20 | spec: 21 | selector: 22 | matchLabels: 23 | app: homepage 24 | template: 25 | metadata: 26 | labels: 27 | app: homepage 28 | spec: 29 | volumes: 30 | - name: shared-data 31 | emptyDir: {} 32 | serviceAccountName: homepage-discovery 33 | containers: 34 | - name: homepage 35 | image: 'ghcr.io/benphelps/homepage:latest' 36 | imagePullPolicy: Always 37 | ports: 38 | - name: http 39 | containerPort: 3000 40 | protocol: TCP 41 | volumeMounts: 42 | - mountPath: "/app/config" # Mount the shared config directory 43 | name: shared-data 44 | - name: homepagesc 45 | image: 'ghcr.io/uatec/homepagesc:latest' 46 | volumeMounts: 47 | - mountPath: "/app/config" # Mount the shared config directory 48 | name: shared-data 49 | env: 50 | - name: OUTPUTLOCATION # Tell HomepageSC to output services in to the shared directory 51 | value: "/app/config/services.yaml" 52 | - name: INCLUSTER # Use kubeconfig provided by the cluster itself 53 | value: "true" 54 | 55 | 56 | # Grant HomepageSC permission to discover ingresses and widget secrets 57 | --- 58 | apiVersion: v1 59 | kind: ServiceAccount 60 | metadata: 61 | name: homepage-discovery 62 | namespace: homepage 63 | --- 64 | apiVersion: rbac.authorization.k8s.io/v1 65 | kind: ClusterRoleBinding 66 | metadata: 67 | name: homepage-discovery 68 | roleRef: 69 | apiGroup: rbac.authorization.k8s.io 70 | kind: ClusterRole 71 | name: cluster-admin 72 | subjects: 73 | - kind: ServiceAccount 74 | name: homepage-discovery 75 | namespace: homepage 76 | ``` 77 | 78 | Configure how your ingress appears with annotations to give Homepage further information about them. 79 | 80 | ```yaml 81 | apiVersion: networking.k8s.io/v1 82 | kind: Ingress 83 | metadata: 84 | name: ghost 85 | namespace: publishing 86 | annotations: 87 | homepagesc.neutrino.io/appName: Awesome Blog 88 | homepagesc.neutrino.io/group: Publishing 89 | homepagesc.neutrino.io/healthCheck: http://ghost.publish.svc.cluster.local:2368/ghost/api/v3/admin/site 90 | homepagesc.neutrino.io/icon: https://github.com/walkxcode/dashboard-icons/raw/main/png/ghost.png 91 | spec: 92 | rules: 93 | - host: www.awesomeblog.com 94 | http: 95 | paths: 96 | - path: / 97 | pathType: Prefix 98 | backend: 99 | service: 100 | name: web 101 | port: 102 | name: http 103 | ``` 104 | 105 | ### Annotations 106 | 107 | HomepageSC exposes extra Homepage configuration options using ingress annotations. These annotations broadly mirror the [service configuration](https://gethomepage.dev/en/configs/services/) of Homepage. 108 | 109 | | Annotation | Description | 110 | |----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 111 | | `homepagesc.neutrino.io/enable` | Override the default enabling behaviour by setting this to `true` to show your service or `false` to hide it. | 112 | | `homepagesc.neutrino.io/appName` | A custom name for your application. Use if you don't want to use the name of the ingress. | 113 | | `homepagesc.neutrino.io/group` | A custom group name. Use if you want the application to show in a different group than the namespace it is running in. | 114 | | `homepagesc.neutrino.io/icon` | Services may have an icon attached to them, you can use icons from [Dashboard Icons](https://github.com/walkxcode/dashboard-icons) automatically, by passing the name of the icon, with, or without `.png`. You can also specify icons from [material design icons](https://materialdesignicons.com) with `mdi-XX`. If you would like to load a remote icon, you may pass the URL to it. If you would like to load a local icon, first create a Docker mount to `/app/public/icons` and then reference your icon as `/icons/myicon.png`. | 115 | | `homepagesc.neutrino.io/description` | Use to provide a subtitle or other additional info to the service tile. | 116 | | `homepagesc.neutrino.io/target` | Changes the behaviour of links on the homepage. Possible options include `_blank`, `_self`, and `_top`. Use _blank to open links in a new tab, _self to open links in the same tab, and _top to open links in a new window. | 117 | | `homepagesc.neutrino.io/healthCheck` | Services may have an optional ping property that allows you to monitor the availability of an endpoint you chose and have the response time displayed. You do not need to set your ping URL equal to your href URL. | 118 | | `homepagesc.neutrino.io/widget_type` | Use to specify the type of widget to be rendered for this service. Widget's are documented on the [service widget](https://gethomepage.dev/en/configs/service-widgets/) page. | 119 | | `homepagesc.neutrino.io/widget_secret` | Specify a widget secret to be added to the `key` field of the widget config. The secret locator format is `{namespace}/{secretName}/{secretField}`. | 120 | 121 | ### Settings 122 | 123 | HomepageSC or global service settings can be configured using environment variables or command line parameters. 124 | 125 | | Env Var | Command Line | Default | Description | 126 | |--------------------|-----------------------------------------------------------|---------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 127 | | `INCLUSTER` | `--InCluster` or `--InCluster=[true,false]` | `FALSE` | By default HomepageSC runs in development mode, using your configured kubeconfig. Set this to tell HomepageSC to retrieve config from the cluster it is running in. | 128 | | `INCLUDEBYDEFAULT` | `--IncludeByDefault` or `--IncludeByDefault=[true,false]` | `true` | Defines the behaviour for ingresses which do not have the `homepagesc.neutrino.io/enable` annotation. Set to false to require services to be explicitly enabled. | 129 | | `OUTPUTLOCATION` | `--OutputLocation=/app/config/service.yaml` | `undefined` | Instruct HomepageSC to write configuration to a location on disk. This should be `service.yaml` within the HomePage configuration directory. Default behaviour is to write config to stdout to validation. | 130 | | `DEFAULTTARGET` | `--DefaultTarget=[_blank,_self,_top]` | `undefined` | Change the behaviour of links for all services (which do not specify their own `homepagesc.neutrino.io/target` annotation). | 131 | 132 | 133 | ## Contributing 134 | 135 | Pull requests are welcome. For major changes, please open an issue first 136 | to discuss what you would like to change. 137 | 138 | Please make sure to update tests as appropriate. 139 | 140 | ## License 141 | 142 | [MIT](https://choosealicense.com/licenses/mit/) 143 | 144 | ## Roadmap 145 | - [x] swappable kubeconfig (commmand line flag) 146 | - [x] configurable output directory 147 | - [ ] include manual config 148 | - [x] ping 149 | - [x] fully qualify back end name 150 | - [x] icon 151 | - [x] test image config 152 | - [ ] homepage to support HTTP 204 as success 153 | - [x] enable services 154 | - [x] from annotation 155 | - [x] default from config 156 | - [x] targets 157 | - [x] from annotations 158 | - [x] default from config 159 | - [ ] widgets 160 | - [x] basic 161 | - [x] secrets 162 | - [x] key 163 | - [ ] multi field 164 | - [x] https ingresses 165 | - [x] move to own namespaced attributes --------------------------------------------------------------------------------