diff --git a/.github/workflows/username_service.yaml b/.github/workflows/username_service.yaml new file mode 100644 index 0000000..b28f6e8 --- /dev/null +++ b/.github/workflows/username_service.yaml @@ -0,0 +1,117 @@ +name: Username Service + +on: + push: + branches: + - master + pull_request: + paths: + - ".github/workflows/username_service.yaml" + - "stfc-username-service/**" + +jobs: + golangci-lint: + name: runner / golangci-lint + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: golangci-lint + uses: golangci/golangci-lint-action@v8.0.0 + with: + working-directory: stfc-username-service/ + + run_tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Run tests in container + uses: docker/build-push-action@v6 + with: + no-cache: true + cache-to: type=gha,mode=max + target: test-stage + push: false + context: "{{defaultContext}}:stfc-username-service" + + push_dev_image_harbor: + runs-on: ubuntu-latest + needs: [run_tests, golangci-lint] + steps: + - uses: actions/checkout@v5 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Harbor + uses: docker/login-action@v3 + with: + registry: harbor.stfc.ac.uk + username: ${{ secrets.STAGING_HARBOR_USERNAME }} + password: ${{ secrets.STAGING_HARBOR_TOKEN }} + + - name: Set commit SHA for later + id: commit_sha + run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Build and push to staging project + uses: docker/build-push-action@v6 + with: + cache-from: type=gha + cache-to: type=gha,mode=max + push: false + context: "{{defaultContext}}:stfc-username-service" + tags: "harbor.stfc.ac.uk/stfc-cloud-staging/stfc-username-service:${{ steps.commit_sha.outputs.sha_short }}" + + - name: Inform of tagged name + run: echo "Image published to harbor.stfc.ac.uk/stfc-cloud-staging/stfc-username-service:${{ steps.commit_sha.outputs.sha_short }}" + + push_release_image_harbor: + runs-on: ubuntu-latest + needs: [run_tests, golangci-lint] + if: github.ref == 'refs/heads/master' + steps: + - uses: actions/checkout@v5 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Harbor + uses: docker/login-action@v3 + with: + registry: harbor.stfc.ac.uk + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_TOKEN }} + + - name: Get release tag for later + id: release_tag + run: echo "version=$(cat stfc-username-service/version.txt)" >> $GITHUB_OUTPUT + + - name: Check if release file has updated + uses: dorny/paths-filter@v3 + id: release_updated + with: + filters: | + version: + - 'stfc-username-service/version.txt' + + - name: Build and push on version change + uses: docker/build-push-action@v6 + if: steps.release_updated.outputs.version == 'true' + with: + cache-from: type=gha + cache-to: type=gha,mode=max + push: true + context: "{{defaultContext}}:stfc-username-service" + tags: "harbor.stfc.ac.uk/stfc-cloud/stfc-username-service:v${{ steps.release_tag.outputs.version }}" + + - name: Inform of tagged name + if: steps.release_updated.outputs.version == 'true' + run: echo "Image published to harbor.stfc.ac.uk/stfc-cloud/stfc-username-service:v${{ steps.release_tag.outputs.version }}" diff --git a/stfc-username-service/Dockerfile b/stfc-username-service/Dockerfile new file mode 100644 index 0000000..2e17530 --- /dev/null +++ b/stfc-username-service/Dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.25-alpine AS build-stage +WORKDIR /app +COPY . . +RUN go mod download && go mod verify +RUN go build -o username . + +# Run the tests in the container +FROM build-stage AS test-stage +RUN go test -v ./... + +FROM alpine:latest AS build-release-stage +WORKDIR / +COPY --from=build-stage /app/username /username + +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +RUN apk add --no-cache tini + +USER appuser +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["./username"] \ No newline at end of file diff --git a/stfc-username-service/client.go b/stfc-username-service/client.go new file mode 100644 index 0000000..6389d2a --- /dev/null +++ b/stfc-username-service/client.go @@ -0,0 +1,47 @@ +package main + +import ( + "context" + "log/slog" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack" + "github.com/gophercloud/gophercloud/v2/openstack/config" + "github.com/gophercloud/gophercloud/v2/openstack/config/clouds" +) + +// Struct containing openstack service clients for interacting with the API +type OpenstackClient struct { + computeClient *gophercloud.ServiceClient + identityClient *gophercloud.ServiceClient +} + +// Creates a new client which can be used to interact with the API +// uses a cloud defined in cloud.yaml +func (c *OpenstackClient) New(cloud string) { + ctx := context.Background() + + authOptions, endpointOptions, tlsConfig, err := clouds.Parse(clouds.WithCloudName(cloud)) + if err != nil { + panic(err) + } + + authOptions.AllowReauth = true + + providerClient, err := config.NewProviderClient(ctx, authOptions, config.WithTLSConfig(tlsConfig)) + if err != nil { + panic(err) + } + + c.computeClient, err = openstack.NewComputeV2(providerClient, endpointOptions) + if err != nil { + slog.Error("Failed to initilize compute client") + panic(err) + } + c.identityClient, err = openstack.NewIdentityV3(providerClient, endpointOptions) + if err != nil { + slog.Error("Failed to initilize identity client") + panic(err) + } + +} diff --git a/stfc-username-service/go.mod b/stfc-username-service/go.mod new file mode 100644 index 0000000..d34ad32 --- /dev/null +++ b/stfc-username-service/go.mod @@ -0,0 +1,7 @@ +module stfc-cloud/username + +go 1.25.1 + +require github.com/gophercloud/gophercloud/v2 v2.8.0 + +require gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/stfc-username-service/go.sum b/stfc-username-service/go.sum new file mode 100644 index 0000000..69739db --- /dev/null +++ b/stfc-username-service/go.sum @@ -0,0 +1,6 @@ +github.com/gophercloud/gophercloud/v2 v2.8.0 h1:of2+8tT6+FbEYHfYC8GBu8TXJNsXYSNm9KuvpX7Neqo= +github.com/gophercloud/gophercloud/v2 v2.8.0/go.mod h1:Ki/ILhYZr/5EPebrPL9Ej+tUg4lqx71/YH2JWVeU+Qk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/stfc-username-service/main.go b/stfc-username-service/main.go new file mode 100644 index 0000000..dc46b15 --- /dev/null +++ b/stfc-username-service/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/users" +) + +type service struct { + client OpenstackClient +} + +// Get the user ID associated with a given server +func getServerUserID(computeClient *gophercloud.ServiceClient, serverID string) (string, error) { + server := servers.Get(context.TODO(), computeClient, serverID) + serverDetails, err := server.Extract() + if err != nil { + slog.Error("Failed to get server details", "ID", serverID) + return "", err + } + + return serverDetails.UserID, nil +} + +// Get the username associated with a given user ID +func getUsername(identityClient *gophercloud.ServiceClient, userID string) (string, error) { + user := users.Get(context.TODO(), identityClient, userID) + userDetails, err := user.Extract() + if err != nil { + slog.Error("Failed to get user details", "ID", userID) + return "", err + } + + return userDetails.Name, nil +} + +// Handle requests to the path: .../getusername?serverID= +func (s service) getUserHandler(w http.ResponseWriter, r *http.Request) { + + serverID := r.URL.Query().Get("serverID") + userID, err := getServerUserID(s.client.identityClient, serverID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + name, err := getUsername(s.client.identityClient, userID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, err = fmt.Fprintf(w, "%s", name) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func main() { + + addr := os.Getenv("ADDR") + if addr == "" { + addr = ":80" + } + + var client OpenstackClient + client.New("openstack") + + service := service{client: client} + + http.HandleFunc("/getusername", service.getUserHandler) + err := http.ListenAndServe(addr, nil) + if err != nil { + slog.Error("Failed to start server", "addr", addr) + panic(err) + } +} diff --git a/stfc-username-service/main_test.go b/stfc-username-service/main_test.go new file mode 100644 index 0000000..068508c --- /dev/null +++ b/stfc-username-service/main_test.go @@ -0,0 +1,160 @@ +package main + +import ( + "fmt" + "net/http" + "testing" + + "github.com/gophercloud/gophercloud/v2/testhelper" + "github.com/gophercloud/gophercloud/v2/testhelper/client" +) + +type response func() + +// Test error on returned status code 404 +func TestUserNameFailure(t *testing.T) { + tests := []struct { + desc string + arg string + fake response + expOutput string + expErr string + }{{ + desc: "user find error", + arg: "abc123", + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + fakeserver := testhelper.SetupHTTP() + defer fakeserver.Teardown() + url := "/users/" + tt.arg + fakeserver.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + c := client.ServiceClient(fakeserver) + + r, err := getUsername(c, tt.arg) + testhelper.AssertErr(t, err) + testhelper.AssertEquals(t, r, "") + }) + } +} + +// Test username is returned when user is present +func TestUserNameSuccess(t *testing.T) { + tests := []struct { + desc string + arg string + fake response + expOutput string + expErr string + }{{ + desc: "user find success", + arg: "abc123", + expOutput: "test_user", + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + fakeserver := testhelper.SetupHTTP() + defer fakeserver.Teardown() + url := "/users/" + tt.arg + fakeserver.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + _, err := fmt.Fprintf(w, ` + { + "user": + { + "id": "abc123", + "name": "test_user" + } + } + `) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) + c := client.ServiceClient(fakeserver) + + r, err := getUsername(c, tt.arg) + testhelper.AssertNoErr(t, err) + testhelper.AssertEquals(t, r, "test_user") + }) + } +} + +// Test error when server 404 status code returned +func TestServerUserIDFailure(t *testing.T) { + tests := []struct { + desc string + arg string + fake response + expOutput string + expErr string + }{{ + desc: "server user ID find error", + arg: "xyz321", + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + fakeserver := testhelper.SetupHTTP() + defer fakeserver.Teardown() + url := "/servers/" + tt.arg + fakeserver.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + c := client.ServiceClient(fakeserver) + + r, err := getServerUserID(c, tt.arg) + testhelper.AssertErr(t, err) + testhelper.AssertEquals(t, r, "") + }) + } +} + +// Test server ID is returned when a server is found +func TestServerUserIDSuccess(t *testing.T) { + tests := []struct { + desc string + arg string + fake response + expOutput string + expErr string + }{{ + desc: "server user ID find success", + arg: "xyz321", + expOutput: "abc123", + }} + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + fakeserver := testhelper.SetupHTTP() + defer fakeserver.Teardown() + url := "/servers/" + tt.arg + fakeserver.Mux.HandleFunc(url, func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + _, err := fmt.Fprintf(w, ` + { + "server": + { + "id": "xyz321", + "user_id": "abc123" + } + } + `) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) + c := client.ServiceClient(fakeserver) + + r, err := getServerUserID(c, tt.arg) + testhelper.AssertNoErr(t, err) + testhelper.AssertEquals(t, r, "abc123") + }) + } +} diff --git a/stfc-username-service/version.txt b/stfc-username-service/version.txt new file mode 100644 index 0000000..afaf360 --- /dev/null +++ b/stfc-username-service/version.txt @@ -0,0 +1 @@ +1.0.0 \ No newline at end of file