A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
81
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix labeler deployment

+322 -177
+6 -8
config-labeler.example.yaml
··· 11 11 enabled: true 12 12 # Listen address for labeler (e.g., :5002). 13 13 addr: :5002 14 - # Externally reachable labeler URL. Empty = derive from server.base_url. 15 - public_url: "" 14 + # Externally reachable labeler URL (required, e.g. https://labeler.example.com). 15 + public_url: https://labeler.example.com 16 + # OAuth client display name (e.g., "ATCR Labeler"). 17 + client_name: ATCR Labeler 18 + # Short brand label used in UI copy (e.g., "ATCR"). 19 + client_short_name: ATCR 16 20 # DID of the labeler admin. Only this DID can log into the admin panel. 17 21 owner_did: did:plc:your-did-here 18 22 # Directory for labeler state (database, signing key, did.txt). ··· 33 37 libsql_auth_token: "" 34 38 # Embedded-replica pull interval (e.g. 30s). 0 = manual sync only. 35 39 libsql_sync_interval: 0s 36 - # AppView server settings (shared config). 37 - server: 38 - base_url: https://atcr.io 39 - client_name: AT Container Registry 40 - client_short_name: ATCR 41 - test_mode: false 42 40 # Remote log shipping settings. 43 41 log_shipper: 44 42 # Log shipping backend: "victoria", "opensearch", or "loki". Empty disables shipping.
+29 -9
deploy/upcloud/cloudinit.go
··· 49 49 S3SecretKey string 50 50 51 51 // Infrastructure (computed from zone + config) 52 - Zone string // e.g. "us-chi1" 53 - HoldDomain string // e.g. "us-chi1.cove.seamark.dev" 54 - HoldDid string // e.g. "did:web:us-chi1.cove.seamark.dev" 55 - BasePath string // e.g. "/var/lib/seamark" 52 + Zone string // e.g. "us-chi1" 53 + HoldDomain string // e.g. "us-chi1.cove.seamark.dev" 54 + HoldDid string // e.g. "did:web:us-chi1.cove.seamark.dev" 55 + LabelerDomain string // e.g. "labeler.seamark.dev" 56 + BasePath string // e.g. "/var/lib/seamark" 56 57 57 58 // Scanner (auto-generated shared secret) 58 59 ScannerSecret string // hex-encoded 32-byte secret; empty disables scanning ··· 373 374 } 374 375 375 376 // syncServiceUnit compares a rendered systemd service unit against what's on 376 - // the server. If they differ, it writes the new unit file. Returns true if the 377 - // unit was updated (caller should daemon-reload before restart). 377 + // the server. If they differ, it writes the new unit file. If the unit is 378 + // missing entirely, it installs it and runs `systemctl enable` so the service 379 + // starts on boot. Returns true if the unit was created or updated (caller 380 + // should daemon-reload before restart). 378 381 func syncServiceUnit(name, ip, serviceName, renderedUnit string) (bool, error) { 379 382 unitPath := "/etc/systemd/system/" + serviceName + ".service" 380 383 ··· 387 390 rendered := strings.TrimSpace(renderedUnit) 388 391 389 392 if remote == "__MISSING__" { 390 - fmt.Printf(" service unit: %s not found (cloud-init will handle it)\n", name) 391 - return false, nil 393 + // First-time install: write file, daemon-reload, and enable so the 394 + // service comes up on boot. The caller's restart will start it. 395 + script := fmt.Sprintf("cat > %s << 'SVCEOF'\n%s\nSVCEOF\nsystemctl daemon-reload\nsystemctl enable %s", 396 + unitPath, rendered, serviceName) 397 + if _, err := runSSH(ip, script, false); err != nil { 398 + return false, fmt.Errorf("install service unit: %w", err) 399 + } 400 + fmt.Printf(" service unit: %s installed and enabled\n", name) 401 + return true, nil 392 402 } 393 403 394 404 if remote == rendered { ··· 416 426 remote = strings.TrimSpace(remote) 417 427 418 428 if remote == "__MISSING__" { 419 - fmt.Printf(" config sync: %s not yet created (cloud-init will handle it)\n", name) 429 + // First-time install: write the rendered template as-is. Subsequent 430 + // runs use the merge-keys path below to preserve operator edits. 431 + dir := configPath[:strings.LastIndex(configPath, "/")] 432 + if _, err := runSSH(ip, fmt.Sprintf("mkdir -p %s", dir), false); err != nil { 433 + return fmt.Errorf("create config dir: %w", err) 434 + } 435 + script := fmt.Sprintf("cat > %s << 'CFGEOF'\n%s\nCFGEOF", configPath, strings.TrimRight(templateYAML, "\n")) 436 + if _, err := runSSH(ip, script, false); err != nil { 437 + return fmt.Errorf("write initial config: %w", err) 438 + } 439 + fmt.Printf(" config sync: %s installed\n", name) 420 440 return nil 421 441 } 422 442
+4 -2
deploy/upcloud/config.go
··· 90 90 return clientName, baseDomain, registryDomains, nil 91 91 } 92 92 93 - // readSSHPublicKey reads an SSH public key from a file path. 93 + // readSSHPublicKey reads an SSH public key from a file path. An empty path 94 + // returns an empty key without error — callers that need the key (e.g. when 95 + // creating new servers) must check for empty before use. 94 96 func readSSHPublicKey(path string) (string, error) { 95 97 if path == "" { 96 - return "", fmt.Errorf("--ssh-key is required (path to SSH public key file)") 98 + return "", nil 97 99 } 98 100 data, err := os.ReadFile(path) 99 101 if err != nil {
+3 -5
deploy/upcloud/configs/labeler.yaml.tmpl
··· 10 10 labeler: 11 11 enabled: true 12 12 addr: :5002 13 + public_url: "https://{{.LabelerDomain}}" 14 + client_name: "Seamark Labeler" 15 + client_short_name: Seamark 13 16 owner_did: "" 14 17 data_dir: "{{.BasePath}}/labeler" 15 18 did_method: plc ··· 17 20 key_path: "" 18 21 rotation_key: "" 19 22 plc_directory_url: https://plc.directory 20 - server: 21 - base_url: "https://seamark.dev" 22 - client_name: Seamark 23 - client_short_name: Seamark 24 - test_mode: false
+248 -71
deploy/upcloud/provision.go
··· 37 37 func init() { 38 38 provisionCmd.Flags().String("zone", "", "UpCloud zone (interactive picker if omitted)") 39 39 provisionCmd.Flags().String("plan", "", "Server plan (interactive picker if omitted)") 40 - provisionCmd.Flags().String("ssh-key", "", "Path to SSH public key file (required)") 40 + provisionCmd.Flags().String("ssh-key", "", "Path to SSH public key file (required when creating new servers)") 41 41 provisionCmd.Flags().String("s3-secret", "", "S3 secret access key (for existing object storage)") 42 42 provisionCmd.Flags().Bool("with-scanner", false, "Deploy vulnerability scanner alongside hold") 43 43 provisionCmd.Flags().Bool("with-labeler", false, "Deploy content moderation labeler alongside appview") 44 - _ = provisionCmd.MarkFlagRequired("ssh-key") 45 44 rootCmd.AddCommand(provisionCmd) 46 45 } 47 46 ··· 154 153 155 154 // Hold domain is zone-based (e.g. us-chi1.cove.seamark.dev) 156 155 holdDomain := cfg.Zone + ".cove." + cfg.BaseDomain 156 + // Labeler domain is a fixed subdomain on the base domain (e.g. labeler.seamark.dev) 157 + labelerDomain := "labeler." + cfg.BaseDomain 157 158 158 159 // Build config template values 159 160 vals := &ConfigValues{ ··· 165 166 Zone: cfg.Zone, 166 167 HoldDomain: holdDomain, 167 168 HoldDid: "did:web:" + holdDomain, 169 + LabelerDomain: labelerDomain, 168 170 BasePath: naming.BasePath(), 169 171 ScannerSecret: state.ScannerSecret, 170 172 } ··· 307 309 fmt.Printf("Load balancer: %s (exists)\n", state.LB.UUID) 308 310 } else { 309 311 fmt.Println("Creating load balancer (Essentials tier)...") 310 - lb, err := createLoadBalancer(ctx, svc, cfg, naming, state.Network.UUID, state.Appview.PrivateIP, state.Hold.PrivateIP, holdDomain) 312 + lb, err := createLoadBalancer(ctx, svc, cfg, naming, state.Network.UUID, state.Appview.PrivateIP, state.Hold.PrivateIP, holdDomain, labelerDomain, state.LabelerEnabled) 311 313 if err != nil { 312 314 return fmt.Errorf("create LB: %w", err) 313 315 } ··· 325 327 return fmt.Errorf("LB hold forwarded headers: %w", err) 326 328 } 327 329 330 + // Ensure labeler backend + route-labeler rule when labeler is enabled 331 + if state.LabelerEnabled { 332 + if err := ensureLBLabelerRoute(ctx, svc, state.LB.UUID, state.Appview.PrivateIP, labelerDomain); err != nil { 333 + return fmt.Errorf("LB labeler route: %w", err) 334 + } 335 + } 336 + 328 337 // Always reconcile scanner block rule 329 338 if err := ensureLBScannerBlock(ctx, svc, state.LB.UUID); err != nil { 330 339 return fmt.Errorf("LB scanner block: %w", err) ··· 334 343 tlsDomains := []string{cfg.BaseDomain} 335 344 tlsDomains = append(tlsDomains, cfg.RegistryDomains...) 336 345 tlsDomains = append(tlsDomains, holdDomain) 346 + if state.LabelerEnabled { 347 + tlsDomains = append(tlsDomains, labelerDomain) 348 + } 337 349 if err := ensureLBCertificates(ctx, svc, state.LB.UUID, tlsDomains); err != nil { 338 350 return fmt.Errorf("LB certificates: %w", err) 339 351 } ··· 428 440 } 429 441 } 430 442 443 + // Labeler binary: build and upload when labeler is enabled but appview was 444 + // not freshly created (the appviewCreated branch above already handled it). 445 + if state.LabelerEnabled && !appviewCreated { 446 + rootDir := projectRoot() 447 + if err := runGenerate(rootDir); err != nil { 448 + return fmt.Errorf("go generate: %w", err) 449 + } 450 + fmt.Println("\nBuilding labeler locally (GOOS=linux GOARCH=amd64)...") 451 + labelerLocal := filepath.Join(rootDir, "bin", "atcr-labeler") 452 + if err := buildLocal(rootDir, labelerLocal, "./cmd/labeler"); err != nil { 453 + return fmt.Errorf("build labeler: %w", err) 454 + } 455 + labelerRemote := naming.InstallDir() + "/bin/" + naming.Labeler() 456 + fmt.Println("Deploying labeler binary...") 457 + if err := scpFile(labelerLocal, state.Appview.PublicIP, labelerRemote); err != nil { 458 + return fmt.Errorf("upload labeler: %w", err) 459 + } 460 + } 461 + 431 462 fmt.Println("\n=== Provisioning Complete ===") 432 463 fmt.Println() 433 464 fmt.Println("DNS records needed:") ··· 437 468 fmt.Printf(" CNAME %-24s → %s\n", rd, lbDNS) 438 469 } 439 470 fmt.Printf(" CNAME %-24s → %s\n", holdDomain, lbDNS) 471 + if state.LabelerEnabled { 472 + fmt.Printf(" CNAME %-24s → %s\n", labelerDomain, lbDNS) 473 + } 440 474 } else { 441 475 fmt.Println(" (LB DNS name not yet available — check 'status' in a few minutes)") 442 476 } ··· 574 608 } 575 609 576 610 func createServer(ctx context.Context, svc *service.Service, cfg *InfraConfig, templateUUID, networkUUID, title, userData string) (*ServerState, error) { 611 + if cfg.SSHPublicKey == "" { 612 + return nil, fmt.Errorf("creating server %s requires --ssh-key (path to SSH public key file)", title) 613 + } 577 614 storageTier := "maxiops" 578 615 if strings.HasPrefix(strings.ToUpper(cfg.Plan), "DEV-") { 579 616 storageTier = "standard" ··· 709 746 }) 710 747 } 711 748 712 - func createLoadBalancer(ctx context.Context, svc *service.Service, cfg *InfraConfig, naming Naming, networkUUID, appviewIP, holdIP, holdDomain string) (*upcloud.LoadBalancer, error) { 749 + func createLoadBalancer(ctx context.Context, svc *service.Service, cfg *InfraConfig, naming Naming, networkUUID, appviewIP, holdIP, holdDomain, labelerDomain string, withLabeler bool) (*upcloud.LoadBalancer, error) { 750 + frontendRules := []request.LoadBalancerFrontendRule{ 751 + { 752 + Name: "set-forwarded-headers", 753 + Priority: 1, 754 + Matchers: []upcloud.LoadBalancerMatcher{}, 755 + Actions: []upcloud.LoadBalancerAction{ 756 + request.NewLoadBalancerSetForwardedHeadersAction(), 757 + }, 758 + }, 759 + { 760 + Name: "route-hold", 761 + Priority: 10, 762 + Matchers: []upcloud.LoadBalancerMatcher{ 763 + { 764 + Type: upcloud.LoadBalancerMatcherTypeHost, 765 + Host: &upcloud.LoadBalancerMatcherHost{ 766 + Value: holdDomain, 767 + }, 768 + }, 769 + }, 770 + Actions: []upcloud.LoadBalancerAction{ 771 + request.NewLoadBalancerSetForwardedHeadersAction(), 772 + { 773 + Type: upcloud.LoadBalancerActionTypeUseBackend, 774 + UseBackend: &upcloud.LoadBalancerActionUseBackend{ 775 + Backend: "hold", 776 + }, 777 + }, 778 + }, 779 + }, 780 + } 781 + 782 + backends := []request.LoadBalancerBackend{ 783 + { 784 + Name: "appview", 785 + Members: []request.LoadBalancerBackendMember{ 786 + { 787 + Name: "appview-1", 788 + Type: upcloud.LoadBalancerBackendMemberTypeStatic, 789 + IP: appviewIP, 790 + Port: 5000, 791 + Weight: 100, 792 + MaxSessions: 1000, 793 + Enabled: true, 794 + }, 795 + }, 796 + Properties: &upcloud.LoadBalancerBackendProperties{ 797 + HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP, 798 + HealthCheckURL: "/health", 799 + }, 800 + }, 801 + { 802 + Name: "hold", 803 + Members: []request.LoadBalancerBackendMember{ 804 + { 805 + Name: "hold-1", 806 + Type: upcloud.LoadBalancerBackendMemberTypeStatic, 807 + IP: holdIP, 808 + Port: 8080, 809 + Weight: 100, 810 + MaxSessions: 1000, 811 + Enabled: true, 812 + }, 813 + }, 814 + Properties: &upcloud.LoadBalancerBackendProperties{ 815 + HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP, 816 + HealthCheckURL: "/xrpc/_health", 817 + }, 818 + }, 819 + } 820 + 821 + if withLabeler { 822 + frontendRules = append(frontendRules, labelerFrontendRule(labelerDomain)) 823 + backends = append(backends, labelerBackend(appviewIP)) 824 + } 825 + 713 826 lb, err := svc.CreateLoadBalancer(ctx, &request.CreateLoadBalancerRequest{ 714 827 Name: naming.LBName(), 715 828 Plan: "essentials", ··· 737 850 Networks: []upcloud.LoadBalancerFrontendNetwork{ 738 851 {Name: "public"}, 739 852 }, 740 - Rules: []request.LoadBalancerFrontendRule{ 741 - { 742 - Name: "set-forwarded-headers", 743 - Priority: 1, 744 - Matchers: []upcloud.LoadBalancerMatcher{}, 745 - Actions: []upcloud.LoadBalancerAction{ 746 - request.NewLoadBalancerSetForwardedHeadersAction(), 747 - }, 748 - }, 749 - { 750 - Name: "route-hold", 751 - Priority: 10, 752 - Matchers: []upcloud.LoadBalancerMatcher{ 753 - { 754 - Type: upcloud.LoadBalancerMatcherTypeHost, 755 - Host: &upcloud.LoadBalancerMatcherHost{ 756 - Value: holdDomain, 757 - }, 758 - }, 759 - }, 760 - Actions: []upcloud.LoadBalancerAction{ 761 - request.NewLoadBalancerSetForwardedHeadersAction(), 762 - { 763 - Type: upcloud.LoadBalancerActionTypeUseBackend, 764 - UseBackend: &upcloud.LoadBalancerActionUseBackend{ 765 - Backend: "hold", 766 - }, 767 - }, 768 - }, 769 - }, 770 - }, 853 + Rules: frontendRules, 771 854 }, 772 855 { 773 856 Name: "http-redirect", ··· 803 886 }, 804 887 }, 805 888 Resolvers: []request.LoadBalancerResolver{}, 806 - Backends: []request.LoadBalancerBackend{ 889 + Backends: backends, 890 + }) 891 + if err != nil { 892 + return nil, err 893 + } 894 + 895 + return lb, nil 896 + } 897 + 898 + // labelerBackend builds the labeler LB backend pointing at the appview server's 899 + // private IP on the labeler listen port. 900 + func labelerBackend(appviewIP string) request.LoadBalancerBackend { 901 + return request.LoadBalancerBackend{ 902 + Name: "labeler", 903 + Members: []request.LoadBalancerBackendMember{ 904 + { 905 + Name: "labeler-1", 906 + Type: upcloud.LoadBalancerBackendMemberTypeStatic, 907 + IP: appviewIP, 908 + Port: 5002, 909 + Weight: 100, 910 + MaxSessions: 1000, 911 + Enabled: true, 912 + }, 913 + }, 914 + Properties: &upcloud.LoadBalancerBackendProperties{ 915 + HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP, 916 + HealthCheckURL: "/.well-known/did.json", 917 + }, 918 + } 919 + } 920 + 921 + // labelerFrontendRule returns a host-match rule routing labelerDomain to the 922 + // labeler backend with forwarded headers. 923 + func labelerFrontendRule(labelerDomain string) request.LoadBalancerFrontendRule { 924 + return request.LoadBalancerFrontendRule{ 925 + Name: "route-labeler", 926 + Priority: 20, 927 + Matchers: []upcloud.LoadBalancerMatcher{ 807 928 { 808 - Name: "appview", 809 - Members: []request.LoadBalancerBackendMember{ 810 - { 811 - Name: "appview-1", 812 - Type: upcloud.LoadBalancerBackendMemberTypeStatic, 813 - IP: appviewIP, 814 - Port: 5000, 815 - Weight: 100, 816 - MaxSessions: 1000, 817 - Enabled: true, 818 - }, 819 - }, 820 - Properties: &upcloud.LoadBalancerBackendProperties{ 821 - HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP, 822 - HealthCheckURL: "/health", 929 + Type: upcloud.LoadBalancerMatcherTypeHost, 930 + Host: &upcloud.LoadBalancerMatcherHost{ 931 + Value: labelerDomain, 823 932 }, 824 933 }, 934 + }, 935 + Actions: []upcloud.LoadBalancerAction{ 936 + request.NewLoadBalancerSetForwardedHeadersAction(), 825 937 { 826 - Name: "hold", 827 - Members: []request.LoadBalancerBackendMember{ 828 - { 829 - Name: "hold-1", 830 - Type: upcloud.LoadBalancerBackendMemberTypeStatic, 831 - IP: holdIP, 832 - Port: 8080, 833 - Weight: 100, 834 - MaxSessions: 1000, 835 - Enabled: true, 836 - }, 837 - }, 838 - Properties: &upcloud.LoadBalancerBackendProperties{ 839 - HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP, 840 - HealthCheckURL: "/xrpc/_health", 938 + Type: upcloud.LoadBalancerActionTypeUseBackend, 939 + UseBackend: &upcloud.LoadBalancerActionUseBackend{ 940 + Backend: "labeler", 841 941 }, 842 942 }, 843 943 }, 844 - }) 845 - if err != nil { 846 - return nil, err 847 944 } 848 - 849 - return lb, nil 850 945 } 851 946 852 947 // ensureLBCertificates reconciles TLS certificate bundles on the load balancer. ··· 1023 1118 } 1024 1119 fmt.Println(" Route-hold forwarded headers: created") 1025 1120 1121 + return nil 1122 + } 1123 + 1124 + // ensureLBLabelerRoute idempotently ensures the LB has a "labeler" backend 1125 + // pointing at the appview server's private IP and a "route-labeler" frontend 1126 + // rule matching labelerDomain. Used to add labeler routing to a pre-existing LB 1127 + // during a re-provision with --with-labeler. 1128 + func ensureLBLabelerRoute(ctx context.Context, svc *service.Service, lbUUID, appviewIP, labelerDomain string) error { 1129 + // 1. Ensure backend exists 1130 + backends, err := svc.GetLoadBalancerBackends(ctx, &request.GetLoadBalancerBackendsRequest{ServiceUUID: lbUUID}) 1131 + if err != nil { 1132 + return fmt.Errorf("get backends: %w", err) 1133 + } 1134 + hasBackend := false 1135 + for _, b := range backends { 1136 + if b.Name == "labeler" { 1137 + hasBackend = true 1138 + break 1139 + } 1140 + } 1141 + if !hasBackend { 1142 + _, err := svc.CreateLoadBalancerBackend(ctx, &request.CreateLoadBalancerBackendRequest{ 1143 + ServiceUUID: lbUUID, 1144 + Backend: labelerBackend(appviewIP), 1145 + }) 1146 + if err != nil { 1147 + return fmt.Errorf("create labeler backend: %w", err) 1148 + } 1149 + fmt.Println(" Labeler backend: created") 1150 + } else { 1151 + fmt.Println(" Labeler backend: exists") 1152 + } 1153 + 1154 + // 2. Ensure frontend rule exists with correct host matcher 1155 + rules, err := svc.GetLoadBalancerFrontendRules(ctx, &request.GetLoadBalancerFrontendRulesRequest{ 1156 + ServiceUUID: lbUUID, 1157 + FrontendName: "https", 1158 + }) 1159 + if err != nil { 1160 + return fmt.Errorf("get frontend rules: %w", err) 1161 + } 1162 + for _, r := range rules { 1163 + if r.Name == "route-labeler" { 1164 + // Verify the host matcher and use_backend action are correct 1165 + hostOK := false 1166 + for _, m := range r.Matchers { 1167 + if m.Host != nil && m.Host.Value == labelerDomain { 1168 + hostOK = true 1169 + break 1170 + } 1171 + } 1172 + backendOK := false 1173 + for _, a := range r.Actions { 1174 + if a.UseBackend != nil && a.UseBackend.Backend == "labeler" { 1175 + backendOK = true 1176 + break 1177 + } 1178 + } 1179 + if hostOK && backendOK { 1180 + fmt.Println(" Route-labeler rule: exists and valid") 1181 + return nil 1182 + } 1183 + fmt.Println(" Route-labeler rule: exists but misconfigured, recreating") 1184 + if err := svc.DeleteLoadBalancerFrontendRule(ctx, &request.DeleteLoadBalancerFrontendRuleRequest{ 1185 + ServiceUUID: lbUUID, 1186 + FrontendName: "https", 1187 + Name: r.Name, 1188 + }); err != nil { 1189 + return fmt.Errorf("delete route-labeler rule: %w", err) 1190 + } 1191 + break 1192 + } 1193 + } 1194 + 1195 + if _, err := svc.CreateLoadBalancerFrontendRule(ctx, &request.CreateLoadBalancerFrontendRuleRequest{ 1196 + ServiceUUID: lbUUID, 1197 + FrontendName: "https", 1198 + Rule: labelerFrontendRule(labelerDomain), 1199 + }); err != nil { 1200 + return fmt.Errorf("create route-labeler rule: %w", err) 1201 + } 1202 + fmt.Println(" Route-labeler rule: created") 1026 1203 return nil 1027 1204 } 1028 1205
+2
deploy/upcloud/update.go
··· 364 364 naming := state.Naming() 365 365 _, baseDomain, _, _ := extractFromAppviewTemplate() 366 366 holdDomain := state.Zone + ".cove." + baseDomain 367 + labelerDomain := "labeler." + baseDomain 367 368 368 369 return &ConfigValues{ 369 370 S3Endpoint: state.ObjectStorage.Endpoint, ··· 374 375 Zone: state.Zone, 375 376 HoldDomain: holdDomain, 376 377 HoldDid: "did:web:" + holdDomain, 378 + LabelerDomain: labelerDomain, 377 379 BasePath: naming.BasePath(), 378 380 ScannerSecret: state.ScannerSecret, 379 381 }
+20 -57
pkg/labeler/config.go
··· 4 4 5 5 import ( 6 6 "fmt" 7 - "net/url" 8 7 "path/filepath" 9 8 "strings" 10 9 "time" ··· 14 13 "atcr.io/pkg/config" 15 14 ) 16 15 17 - // Config represents the labeler service configuration. 18 - // It reuses the appview config YAML structure, reading from the "labeler" section. 16 + // Config represents the labeler service configuration. It is fully self-contained: 17 + // no fields are inherited from or shared with the appview config. 19 18 type Config struct { 20 19 Version string `yaml:"version" comment:"Configuration format version."` 21 20 LogLevel string `yaml:"log_level" comment:"Log level: debug, info, warn, error."` 22 21 Labeler LabelerConfig `yaml:"labeler" comment:"Labeler service settings."` 23 - Server AppviewServerConfig `yaml:"server" comment:"AppView server settings (shared config)."` 24 22 LogShipper config.LogShipperConfig `yaml:"log_shipper" comment:"Remote log shipping settings."` 25 23 } 26 24 ··· 32 30 // Listen address for the labeler HTTP server. 33 31 Addr string `yaml:"addr" comment:"Listen address for labeler (e.g., :5002)."` 34 32 35 - // PublicURL is the externally reachable URL of the labeler. When empty the URL is 36 - // derived from server.base_url by prefixing "labeler." (so https://atcr.io → 37 - // https://labeler.atcr.io). Set explicitly for IP-based dev environments. 38 - PublicURL string `yaml:"public_url" comment:"Externally reachable labeler URL. Empty = derive from server.base_url."` 33 + // PublicURL is the externally reachable URL of the labeler. Required. 34 + PublicURL string `yaml:"public_url" comment:"Externally reachable labeler URL (required, e.g. https://labeler.example.com)."` 35 + 36 + // ClientName is the OAuth client display name shown to PDS users on consent screens. 37 + ClientName string `yaml:"client_name" comment:"OAuth client display name (e.g., \"ATCR Labeler\")."` 38 + 39 + // ClientShortName is a shorter brand label used in UI copy. 40 + ClientShortName string `yaml:"client_short_name" comment:"Short brand label used in UI copy (e.g., \"ATCR\")."` 39 41 40 42 // DID of the labeler admin. Only this DID can log into the admin panel. 41 43 OwnerDID string `yaml:"owner_did" comment:"DID of the labeler admin. Only this DID can log into the admin panel."` ··· 69 71 LibsqlSyncInterval time.Duration `yaml:"libsql_sync_interval" comment:"Embedded-replica pull interval (e.g. 30s). 0 = manual sync only."` 70 72 } 71 73 72 - // AppviewServerConfig is a subset of the appview ServerConfig that the labeler needs. 73 - type AppviewServerConfig struct { 74 - BaseURL string `yaml:"base_url"` 75 - ClientName string `yaml:"client_name"` 76 - ClientShortName string `yaml:"client_short_name"` 77 - TestMode bool `yaml:"test_mode"` 78 - } 79 - 80 - // PublicURL returns the labeler's externally reachable URL. When labeler.public_url 81 - // is set explicitly it wins; otherwise it's derived from server.base_url by prefixing 82 - // "labeler." (so https://atcr.io → https://labeler.atcr.io). 74 + // PublicURL returns the labeler's externally reachable URL. 83 75 func (c *Config) PublicURL() string { 84 - if c.Labeler.PublicURL != "" { 85 - return c.Labeler.PublicURL 86 - } 87 - u, err := url.Parse(c.Server.BaseURL) 88 - if err != nil { 89 - return "" 90 - } 91 - u.Host = "labeler." + u.Host 92 - return u.String() 76 + return c.Labeler.PublicURL 93 77 } 94 78 95 79 // DBPath returns the path to the SQLite database file inside the data dir. ··· 121 105 v.SetDefault("labeler.enabled", false) 122 106 v.SetDefault("labeler.addr", ":5002") 123 107 v.SetDefault("labeler.public_url", "") 108 + v.SetDefault("labeler.client_name", "ATCR Labeler") 109 + v.SetDefault("labeler.client_short_name", "ATCR") 124 110 v.SetDefault("labeler.owner_did", "") 125 111 v.SetDefault("labeler.data_dir", "/var/lib/atcr-labeler") 126 112 v.SetDefault("labeler.did_method", "plc") ··· 131 117 v.SetDefault("labeler.libsql_sync_url", "") 132 118 v.SetDefault("labeler.libsql_auth_token", "") 133 119 v.SetDefault("labeler.libsql_sync_interval", 0) 134 - 135 - // Server defaults (read from shared appview config) 136 - v.SetDefault("server.base_url", "") 137 - v.SetDefault("server.client_name", "AT Container Registry") 138 - v.SetDefault("server.client_short_name", "ATCR") 139 - v.SetDefault("server.test_mode", false) 140 120 } 141 121 142 - // LoadConfig loads the labeler configuration from the appview config YAML. 122 + // LoadConfig loads the labeler configuration from a YAML file. 143 123 func LoadConfig(yamlPath string) (*Config, error) { 144 124 v := config.NewViper("LABELER", yamlPath) 145 125 setDefaults(v) ··· 149 129 return nil, fmt.Errorf("failed to unmarshal config: %w", err) 150 130 } 151 131 152 - // Also try ATCR_ prefix for shared server config 153 - atcrV := config.NewViper("ATCR", yamlPath) 154 - if baseURL := atcrV.GetString("server.base_url"); baseURL != "" && cfg.Server.BaseURL == "" { 155 - cfg.Server.BaseURL = baseURL 156 - } 157 - if clientName := atcrV.GetString("server.client_name"); clientName != "" && cfg.Server.ClientName == "" { 158 - cfg.Server.ClientName = clientName 159 - } 160 - if clientShortName := atcrV.GetString("server.client_short_name"); clientShortName != "" && cfg.Server.ClientShortName == "" { 161 - cfg.Server.ClientShortName = clientShortName 162 - } 163 - if atcrV.GetBool("server.test_mode") { 164 - cfg.Server.TestMode = true 165 - } 166 - 167 132 // Validation 168 - if cfg.Server.BaseURL == "" { 169 - return nil, fmt.Errorf("server.base_url is required") 133 + if cfg.Labeler.PublicURL == "" { 134 + return nil, fmt.Errorf("labeler.public_url is required") 170 135 } 171 136 if cfg.Labeler.OwnerDID == "" { 172 137 return nil, fmt.Errorf("labeler.owner_did is required") ··· 188 153 cfg := &Config{ 189 154 Version: "0.1", 190 155 LogLevel: "info", 191 - Server: AppviewServerConfig{ 192 - BaseURL: "https://atcr.io", 193 - ClientName: "AT Container Registry", 194 - ClientShortName: "ATCR", 195 - }, 196 156 Labeler: LabelerConfig{ 197 157 Enabled: true, 198 158 Addr: ":5002", 159 + PublicURL: "https://labeler.example.com", 160 + ClientName: "ATCR Labeler", 161 + ClientShortName: "ATCR", 199 162 OwnerDID: "did:plc:your-did-here", 200 163 DataDir: "/var/lib/atcr-labeler", 201 164 DIDMethod: "plc",
+4 -19
pkg/labeler/config_test.go
··· 3 3 import "testing" 4 4 5 5 func TestConfig_PublicURL(t *testing.T) { 6 - tests := []struct { 7 - name string 8 - baseURL string 9 - want string 10 - }{ 11 - {"standard", "https://atcr.io", "https://labeler.atcr.io"}, 12 - {"with port", "https://atcr.io:8080", "https://labeler.atcr.io:8080"}, 13 - {"localhost", "http://localhost:5000", "http://labeler.localhost:5000"}, 6 + cfg := &Config{ 7 + Labeler: LabelerConfig{PublicURL: "https://labeler.atcr.io"}, 14 8 } 15 - 16 - for _, tt := range tests { 17 - t.Run(tt.name, func(t *testing.T) { 18 - cfg := &Config{ 19 - Server: AppviewServerConfig{BaseURL: tt.baseURL}, 20 - } 21 - got := cfg.PublicURL() 22 - if got != tt.want { 23 - t.Errorf("PublicURL() = %q, want %q", got, tt.want) 24 - } 25 - }) 9 + if got := cfg.PublicURL(); got != "https://labeler.atcr.io" { 10 + t.Errorf("PublicURL() = %q, want %q", got, "https://labeler.atcr.io") 26 11 } 27 12 }
+2 -2
pkg/labeler/handlers.go
··· 39 39 <button type="submit">Sign In</button> 40 40 </form> 41 41 </body></html>`, 42 - s.config.Server.ClientShortName, 43 - s.config.Server.ClientShortName, 42 + s.config.Labeler.ClientShortName, 43 + s.config.Labeler.ClientShortName, 44 44 func() string { 45 45 if errorMsg != "" { 46 46 return fmt.Sprintf(`<div class="error">%s</div>`, template.HTMLEscapeString(errorMsg))
+1 -1
pkg/labeler/identity.go
··· 58 58 publicURL := s.config.PublicURL() 59 59 metadata := map[string]any{ 60 60 "client_id": publicURL + "/oauth-client-metadata.json", 61 - "client_name": fmt.Sprintf("%s Labeler", s.config.Server.ClientShortName), 61 + "client_name": s.config.Labeler.ClientName, 62 62 "client_uri": publicURL, 63 63 "redirect_uris": []string{publicURL + "/auth/oauth/callback"}, 64 64 "scope": "atproto",
+3 -3
pkg/labeler/takedown.go
··· 462 462 <a href="/auth/logout">Logout</a> 463 463 </nav> 464 464 <h2>Active Takedowns (%d)</h2>`, 465 - s.config.Server.ClientShortName, 466 - s.config.Server.ClientShortName, 465 + s.config.Labeler.ClientShortName, 466 + s.config.Labeler.ClientShortName, 467 467 activeTotal, 468 468 ) 469 469 ··· 636 636 <a href="/" class="nav-btn">Dashboard</a> 637 637 <a href="/takedown" class="nav-btn">New Takedown</a> 638 638 </nav>`, 639 - s.config.Server.ClientShortName, 639 + s.config.Labeler.ClientShortName, 640 640 ) 641 641 642 642 if msg != "" {